Skip to content

Commit d55e697

Browse files
authored
Add provider-aware SME conversation auth (#387)
- Persist provider and auth method for SME conversations - Route chat sends through Anthropic or provider runtime after validation - Add migration and UI updates for editing conversation settings
1 parent e4ddb99 commit d55e697

25 files changed

Lines changed: 1812 additions & 337 deletions

apps/server/src/persistence/Layers/SmeConversations.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,18 @@ const makeSmeConversationRepository = Effect.gen(function* () {
2828
execute: (row) =>
2929
sql`
3030
INSERT INTO sme_conversations (
31-
conversation_id, project_id, title, model,
31+
conversation_id, project_id, title, provider, auth_method, model,
3232
created_at, updated_at, deleted_at
3333
)
3434
VALUES (
35-
${row.conversationId}, ${row.projectId}, ${row.title}, ${row.model},
35+
${row.conversationId}, ${row.projectId}, ${row.title}, ${row.provider}, ${row.authMethod}, ${row.model},
3636
${row.createdAt}, ${row.updatedAt}, ${row.deletedAt}
3737
)
3838
ON CONFLICT (conversation_id)
3939
DO UPDATE SET
4040
title = excluded.title,
41+
provider = excluded.provider,
42+
auth_method = excluded.auth_method,
4143
model = excluded.model,
4244
updated_at = excluded.updated_at,
4345
deleted_at = excluded.deleted_at
@@ -53,6 +55,8 @@ const makeSmeConversationRepository = Effect.gen(function* () {
5355
conversation_id AS "conversationId",
5456
project_id AS "projectId",
5557
title,
58+
provider,
59+
auth_method AS "authMethod",
5660
model,
5761
created_at AS "createdAt",
5862
updated_at AS "updatedAt",
@@ -71,6 +75,8 @@ const makeSmeConversationRepository = Effect.gen(function* () {
7175
conversation_id AS "conversationId",
7276
project_id AS "projectId",
7377
title,
78+
provider,
79+
auth_method AS "authMethod",
7480
model,
7581
created_at AS "createdAt",
7682
updated_at AS "updatedAt",

apps/server/src/persistence/Migrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import Migration0016 from "./Migrations/016_ProjectionThreadsInteractionModeChat
3131
import Migration0017 from "./Migrations/017_EnvironmentVariables.ts";
3232
import Migration0018 from "./Migrations/018_ProjectionThreadsGithubRef.ts";
3333
import Migration0019 from "./Migrations/019_SmeKnowledgeBase.ts";
34+
import Migration0020 from "./Migrations/020_SmeConversationProviderAuth.ts";
3435
import { Effect } from "effect";
3536

3637
/**
@@ -63,6 +64,7 @@ const loader = Migrator.fromRecord({
6364
"17_EnvironmentVariables": Migration0017,
6465
"18_ProjectionThreadsGithubRef": Migration0018,
6566
"19_SmeKnowledgeBase": Migration0019,
67+
"20_SmeConversationProviderAuth": Migration0020,
6668
});
6769

6870
/**
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Effect from "effect/Effect";
2+
import * as SqlClient from "effect/unstable/sql/SqlClient";
3+
4+
export default Effect.gen(function* () {
5+
const sql = yield* SqlClient.SqlClient;
6+
7+
yield* sql`
8+
ALTER TABLE sme_conversations
9+
ADD COLUMN provider TEXT NOT NULL DEFAULT 'claudeAgent'
10+
`.pipe(Effect.catch(() => Effect.void));
11+
12+
yield* sql`
13+
ALTER TABLE sme_conversations
14+
ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'auto'
15+
`.pipe(Effect.catch(() => Effect.void));
16+
17+
yield* sql`
18+
UPDATE sme_conversations
19+
SET provider = CASE
20+
WHEN lower(model) LIKE 'claude-%' THEN 'claudeAgent'
21+
WHEN lower(model) LIKE 'gpt-%' THEN 'codex'
22+
WHEN lower(model) LIKE 'openclaw/%' OR lower(model) = 'default' THEN 'openclaw'
23+
ELSE 'claudeAgent'
24+
END
25+
WHERE provider IS NULL
26+
OR provider = ''
27+
OR provider = 'claudeAgent'
28+
`;
29+
30+
yield* sql`
31+
UPDATE sme_conversations
32+
SET auth_method = 'auto'
33+
WHERE auth_method IS NULL OR auth_method = ''
34+
`;
35+
});

apps/server/src/persistence/Services/SmeConversations.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
*
66
* @module SmeConversationRepository
77
*/
8-
import { IsoDateTime, ProjectId, SmeConversationId } from "@okcode/contracts";
8+
import {
9+
IsoDateTime,
10+
ProjectId,
11+
ProviderKind,
12+
SmeAuthMethod,
13+
SmeConversationId,
14+
} from "@okcode/contracts";
915
import { Option, Schema, ServiceMap } from "effect";
1016
import type { Effect } from "effect";
1117

@@ -15,6 +21,8 @@ export const SmeConversationRow = Schema.Struct({
1521
conversationId: SmeConversationId,
1622
projectId: ProjectId,
1723
title: Schema.String,
24+
provider: ProviderKind,
25+
authMethod: SmeAuthMethod,
1826
model: Schema.String,
1927
createdAt: IsoDateTime,
2028
updatedAt: IsoDateTime,

apps/server/src/sme/Layers/SmeChatServiceLive.test.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ProjectId, SmeConversationId, type EnvironmentVariableEntry } from "@okcode/contracts";
2-
import { Effect, Layer, Option } from "effect";
2+
import { Effect, Layer, Option, Stream } from "effect";
33
import { afterEach, describe, expect, it, vi } from "vitest";
44

55
import {
@@ -21,6 +21,10 @@ import {
2121
type SmeMessageRepositoryShape,
2222
type SmeMessageRow,
2323
} from "../../persistence/Services/SmeMessages.ts";
24+
import {
25+
ProviderService,
26+
type ProviderServiceShape,
27+
} from "../../provider/Services/ProviderService.ts";
2428
import { SmeChatService } from "../Services/SmeChatService.ts";
2529
import { makeSmeChatServiceLive } from "./SmeChatServiceLive.ts";
2630

@@ -154,6 +158,21 @@ function makeMessageRepository() {
154158
return { repository, rowsByConversation };
155159
}
156160

161+
function makeProviderService(): ProviderServiceShape {
162+
return {
163+
startSession: () => Effect.die("unexpected provider startSession"),
164+
sendTurn: () => Effect.die("unexpected provider sendTurn"),
165+
interruptTurn: () => Effect.void,
166+
respondToRequest: () => Effect.void,
167+
respondToUserInput: () => Effect.void,
168+
stopSession: () => Effect.void,
169+
listSessions: () => Effect.succeed([]),
170+
getCapabilities: () => Effect.die("unexpected provider getCapabilities"),
171+
rollbackConversation: () => Effect.void,
172+
streamEvents: Stream.empty,
173+
};
174+
}
175+
157176
describe("SmeChatServiceLive", () => {
158177
it("uses persisted Anthropic credentials for a successful send and stores the final reply", async () => {
159178
setAnthropicEnv({
@@ -168,6 +187,8 @@ describe("SmeChatServiceLive", () => {
168187
conversationId,
169188
projectId,
170189
title: "Architecture Q&A",
190+
provider: "claudeAgent",
191+
authMethod: "apiKey",
171192
model: "claude-sonnet-4-6",
172193
createdAt: "2026-01-01T00:00:00.000Z",
173194
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -209,6 +230,7 @@ describe("SmeChatServiceLive", () => {
209230
Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])),
210231
),
211232
Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)),
233+
Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())),
212234
);
213235

214236
const events: Array<unknown> = [];
@@ -287,6 +309,8 @@ describe("SmeChatServiceLive", () => {
287309
conversationId,
288310
projectId,
289311
title: "Docs sync",
312+
provider: "claudeAgent",
313+
authMethod: "apiKey",
290314
model: "claude-sonnet-4-6",
291315
createdAt: "2026-01-01T00:00:00.000Z",
292316
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -302,6 +326,7 @@ describe("SmeChatServiceLive", () => {
302326
Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])),
303327
),
304328
Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)),
329+
Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())),
305330
);
306331

307332
await expect(
@@ -314,11 +339,15 @@ describe("SmeChatServiceLive", () => {
314339
});
315340
}).pipe(Effect.provide(layer)),
316341
),
317-
).rejects.toThrow(
318-
"SmeChatError in sendMessage:auth: SME Chat requires ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN.",
319-
);
342+
).rejects.toThrow("SmeChatError in sendMessage:validate: Anthropic API key is missing.");
320343

321344
expect(createClient).not.toHaveBeenCalled();
322-
expect(rowsByConversation.get(conversationId) ?? []).toEqual([]);
345+
expect(rowsByConversation.get(conversationId)).toEqual([
346+
expect.objectContaining({
347+
role: "user",
348+
text: "Can you summarize the docs?",
349+
isStreaming: false,
350+
}),
351+
]);
323352
});
324353
});

0 commit comments

Comments
 (0)