diff --git a/apps/server/src/providers/catalog.ts b/apps/server/src/providers/catalog.ts index 39fadc7..8164084 100644 --- a/apps/server/src/providers/catalog.ts +++ b/apps/server/src/providers/catalog.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import type { Capabilities, LanguageModelV4, ModelProvider, ModelRef } from '@scn-chat/plugin-api' import type { Db } from '../db/index.ts' import type { Logger } from '../logger.ts' @@ -48,6 +49,13 @@ function defaultOf(models: AdminModel[]): ModelRef | undefined { return model ? { provider: model.provider, id: model.id } : undefined } +/** A session ID for a conversation that doesn't reveal its owner's DID. */ +function sessionOf(conversation: string | undefined): string | undefined { + return conversation + ? createHash('sha256').update(conversation).digest('hex').slice(0, 32) + : undefined +} + /** Which models a user can use, and how to reach them. */ export class ModelCatalog { private readonly deps: CatalogDeps @@ -125,8 +133,12 @@ export class ModelCatalog { return [...listed.values()] } - /** Resolve a model reference for a user: their own key first, then the admin key if their roles allow it. */ - async resolve(did: string, ref: ModelRef): Promise { + /** + * Resolve a model reference for a user: their own key first, then the admin key if their roles allow it. + * `conversation` is the URI of the conversation the call is for, when there is one. + */ + async resolve(did: string, ref: ModelRef, conversation?: string): Promise { + const session = sessionOf(conversation) const own = await credentialFor(this.deps.db, this.deps.box, did, ref) if (own) { const provider = this.deps.providers.get(own.summary.providerId) @@ -137,6 +149,7 @@ export class ModelCatalog { apiKey: own.apiKey, baseURL: own.summary.baseUrl ?? undefined, fetch: endpoint && !provider.allowPrivateNetworks ? this.deps.guardedFetch : undefined, + session, }) return { ref, provider, model, capabilities: own.info.capabilities } } @@ -150,7 +163,7 @@ export class ModelCatalog { return { ref, provider, - model: provider.createModel({ modelId: ref.id }), + model: provider.createModel({ modelId: ref.id, session }), capabilities: admin.capabilities, } } diff --git a/apps/server/src/providers/generate-text.ts b/apps/server/src/providers/generate-text.ts index c0a5dcc..3e6884d 100644 --- a/apps/server/src/providers/generate-text.ts +++ b/apps/server/src/providers/generate-text.ts @@ -7,7 +7,7 @@ import { mapEffort } from './effort.ts' /** Make one-off model calls for plugins, resolving the model the same way as a turn. */ export function createGenerateText(catalog: ModelCatalog, logger: Logger) { return async (request: GenerateTextRequest): Promise<{ text: string; finishReason: string }> => { - const resolved = await catalog.resolve(request.user, request.model) + const resolved = await catalog.resolve(request.user, request.model, request.conversation) const result = await generateText({ model: resolved.model, instructions: request.system, diff --git a/apps/server/src/turns/runner.ts b/apps/server/src/turns/runner.ts index f7b1c57..d06eeff 100644 --- a/apps/server/src/turns/runner.ts +++ b/apps/server/src/turns/runner.ts @@ -454,7 +454,7 @@ export class TurnRunner { try { if (!model) throw new Error('No model selected. Choose a model or set a default in your preferences.') - const resolved = await catalog.resolve(did, model) + const resolved = await catalog.resolve(did, model, conversationUri) const branch = branchTo(loaded.messages, userMessage.rkey) const requested = generation.tools if (requested?.length && !resolved.capabilities.tools) diff --git a/apps/server/tests/helpers/providers.ts b/apps/server/tests/helpers/providers.ts index 0d6cba6..287cea5 100644 --- a/apps/server/tests/helpers/providers.ts +++ b/apps/server/tests/helpers/providers.ts @@ -1,10 +1,10 @@ -import type { ModelProvider } from '@scn-chat/plugin-api' +import type { ModelAccess, ModelProvider } from '@scn-chat/plugin-api' import { MockLanguageModelV4 } from 'ai/test' import { vi } from 'vitest' /** A provider whose models record how they were created. */ export function fakeProvider(overrides: Partial = {}) { - const created: { modelId: string; apiKey?: string; baseURL?: string; fetch?: unknown }[] = [] + const created: ModelAccess[] = [] const provider: ModelProvider = { id: 'fake', name: 'Fake', diff --git a/apps/server/tests/providers/catalog.test.ts b/apps/server/tests/providers/catalog.test.ts index 36124fb..3046b8d 100644 --- a/apps/server/tests/providers/catalog.test.ts +++ b/apps/server/tests/providers/catalog.test.ts @@ -143,6 +143,22 @@ describe('ModelCatalog.resolve', () => { await catalog.resolve('did:plc:alice', { provider: 'user:home', id: 'qwen' }) expect(created[0]?.fetch).toBeUndefined() }) + + it('gives the provider a session ID per conversation that does not reveal the DID', async () => { + const { provider, created } = fakeProvider() + const { catalog } = await catalogWith([model()], provider) + const ref = { provider: 'fake', id: 'big' } + const chat = 'at://did:plc:alice/network.sharedcomputer.chat.conversation/abc' + await catalog.resolve('did:plc:alice', ref, chat) + await catalog.resolve('did:plc:alice', ref, chat) + await catalog.resolve('did:plc:alice', ref, `${chat}d`) + await catalog.resolve('did:plc:alice', ref) + const sessions = created.map((access) => access.session) + expect(sessions[0]).toMatch(/^[0-9a-f]{32}$/) + expect(sessions[1]).toBe(sessions[0]) + expect(sessions[2]).not.toBe(sessions[0]) + expect(sessions[3]).toBeUndefined() + }) }) describe('ModelCatalog.listForUser', () => { diff --git a/apps/server/tests/providers/generate-text.test.ts b/apps/server/tests/providers/generate-text.test.ts index e9abb37..b8ac4d7 100644 --- a/apps/server/tests/providers/generate-text.test.ts +++ b/apps/server/tests/providers/generate-text.test.ts @@ -33,7 +33,21 @@ describe('createGenerateText', () => { it('resolves the model for the user and returns the text and finish reason', async () => { const { catalog, generate } = setup(false) expect(await generate(request)).toEqual({ text: 'A short title', finishReason: 'stop' }) - expect(catalog.resolve).toHaveBeenCalledWith('did:plc:alice', { provider: 'fake', id: 'm' }) + expect(catalog.resolve).toHaveBeenCalledWith( + 'did:plc:alice', + { provider: 'fake', id: 'm' }, + undefined, + ) + }) + + it("resolves the model for the request's conversation", async () => { + const { catalog, generate } = setup(false) + await generate({ ...request, conversation: 'at://did:plc:alice/chat/abc' }) + expect(catalog.resolve).toHaveBeenCalledWith( + 'did:plc:alice', + { provider: 'fake', id: 'm' }, + 'at://did:plc:alice/chat/abc', + ) }) it('passes the requested effort to a reasoning model', async () => { diff --git a/docs/plugins.md b/docs/plugins.md index 6f27b10..d02b571 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -140,7 +140,7 @@ A provider connects SCN Chat to a model API. See [`plugins/openai-compatible`](. | `allowPrivateNetworks` | True if user endpoints are allowed to use private network addresses, such as a self-hosted model on a home network. Off by default. | | `replay` | Set to `'replay'` to send earlier reasoning and provider data back on later turns (some providers require this). `'drop'` leaves them out. | | `providerOptions` | AI SDK provider options. Optional. | -| `createModel(access)` | Returns the model. `access.apiKey` is the user's own key when they have one; otherwise use the admin key. When `access.fetch` is set, the model must use it: it guards user endpoints against private addresses. | +| `createModel(access)` | Returns the model. `access.apiKey` is the user's own key when they have one; otherwise use the admin key. When `access.fetch` is set, the model must use it: it guards user endpoints against private addresses. `access.session` is an opaque ID that stays the same for one conversation, for APIs that route or cache by session. | | `listModels(access)` | Lists the models that can be used. Optional. | ## Tools @@ -215,7 +215,7 @@ If `userSettings` is configured users will see a settings form for the plugin on ## Calling models and changing records -- Use `ctx.models.generateText({ user, model, system, prompt, maxOutputTokens, effort })` for a one-off model call. The model resolves as it would in a chat (previously used model => user default => system default). It returns `{ text, finishReason }`. +- Use `ctx.models.generateText({ user, model, system, prompt, maxOutputTokens, effort, conversation })` for a one-off model call. Pass `conversation`, the conversation's URI, when the call is about one, so providers get its session ID. The model resolves as it would in a chat (previously used model => user default => system default). It returns `{ text, finishReason }`. - Use `ctx.conversations.updateInfo(user, conversation, patch, options?)` to update a conversation's `info` record. Pass `{ unlessUserTitled: true }` so a title the user set won't be overwritten. These are the only ways a plugin should touch the user's data. Never write records via any other route. diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index a9d0d9a..012256c 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -39,6 +39,8 @@ export type ModelAccess = { baseURL?: string /** A fetch the provider must use for user endpoints, guarded against private networks. */ fetch?: typeof globalThis.fetch + /** An opaque ID, stable for one conversation, for endpoints that route or cache by session. */ + session?: string } export interface ModelProvider { @@ -193,6 +195,8 @@ export type GenerateTextRequest = { maxOutputTokens?: number /** Ignored by models without reasoning. Unset uses the provider default. */ effort?: Effort + /** The conversation the call is for, so the provider can keep it with that conversation's turns. */ + conversation?: string signal?: AbortSignal } diff --git a/plugins/openai-compatible/src/index.ts b/plugins/openai-compatible/src/index.ts index 634d3aa..1b84770 100644 --- a/plugins/openai-compatible/src/index.ts +++ b/plugins/openai-compatible/src/index.ts @@ -20,6 +20,19 @@ export const optionsSchema = z title: 'User endpoints can reach private networks', description: 'For self-hosted local models.', }), + sessionHeader: z + .string() + .regex(/^[A-Za-z0-9-]+$/) + .optional() + .meta({ + title: 'Session header', + description: + "A header that carries an ID for each conversation, for endpoints that require one, such as 'x-opencode-session' for OpenCode Go.", + }), + userAgent: z.string().min(1).optional().meta({ + title: 'User agent', + description: "Sent in place of the SDK's, for endpoints that ask clients to name themselves.", + }), }) .strict() @@ -28,6 +41,14 @@ export type OpenAICompatibleOptions = z.infer /** Each endpoint is its own instance, with its own provider ID. */ export const multipleInstances = true +/** The headers the admin set for this endpoint, with the conversation's session ID when there is one. */ +function headersFor(options: OpenAICompatibleOptions, session?: string): Record { + return { + ...(options.userAgent ? { 'user-agent': options.userAgent } : {}), + ...(options.sessionHeader && session ? { [options.sessionHeader]: session } : {}), + } +} + const modelList = z.object({ data: z.array(z.object({ id: z.string().min(1) })) }) /** Any OpenAI-compatible endpoint: OpenRouter, scn, co/core, llama.cpp, vLLM, Ollama, and others. */ @@ -46,13 +67,14 @@ export default function openaiCompatible(options: OpenAICompatibleOptions) { userEndpoints: options.userEndpoints ?? false, allowPrivateNetworks: options.allowPrivateNetworks ?? false, replay: 'drop', - createModel: ({ modelId, apiKey, baseURL, fetch }) => { + createModel: ({ modelId, apiKey, baseURL, fetch, session }) => { const url = baseURL ?? options.baseURL if (!url) throw new Error(`Provider "${options.id}" has no base URL`) return createOpenAICompatible({ name: options.id, baseURL: url, apiKey: apiKey ?? options.apiKey, + headers: headersFor(options, session), includeUsage: true, fetch, })(modelId) @@ -62,7 +84,7 @@ export default function openaiCompatible(options: OpenAICompatibleOptions) { if (!url) throw new Error(`Provider "${options.id}" has no base URL`) const key = apiKey ?? options.apiKey const response = await fetch(`${url.replace(/\/$/, '')}/models`, { - headers: key ? { authorization: `Bearer ${key}` } : {}, + headers: { ...headersFor(options), ...(key ? { authorization: `Bearer ${key}` } : {}) }, redirect: 'error', }) if (!response.ok) throw new Error(`Listing models at ${url} returned ${response.status}`) diff --git a/plugins/openai-compatible/tests/index.test.ts b/plugins/openai-compatible/tests/index.test.ts index 5515bad..37dc77c 100644 --- a/plugins/openai-compatible/tests/index.test.ts +++ b/plugins/openai-compatible/tests/index.test.ts @@ -9,6 +9,15 @@ const cocore = { apiKey: 'admin-key', } +const completion = { + id: 'c', + object: 'chat.completion', + created: 1, + model: 'm', + choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, +} + describe('openai-compatible plugin', () => { it('offers admin models for a keyless local server with only a base URL', async () => { const { providers } = await setupForTest( @@ -25,18 +34,7 @@ describe('openai-compatible plugin', () => { const { providers } = await setupForTest( plugin({ id: 'custom', name: 'Custom', userEndpoints: true }), ) - const fetch = vi.fn(async () => - Response.json({ - id: 'c', - object: 'chat.completion', - created: 1, - model: 'm', - choices: [ - { index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1 }, - }), - ) + const fetch = vi.fn(async () => Response.json(completion)) const model = providers[0]!.createModel({ modelId: 'llama', apiKey: 'user-key', @@ -51,6 +49,43 @@ describe('openai-compatible plugin', () => { expect(new Headers(init.headers).get('authorization')).toBe('Bearer user-key') }) + it('sends the session header and user agent the admin set', async () => { + const { providers } = await setupForTest( + plugin({ ...cocore, sessionHeader: 'x-opencode-session', userAgent: 'scn-chat/1.0' }), + ) + const fetch = vi.fn(async () => Response.json(completion)) + const model = providers[0]!.createModel({ + modelId: 'kimi', + session: 'abc123', + fetch: fetch as unknown as typeof globalThis.fetch, + }) + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + } as never) + const headers = new Headers( + (fetch.mock.calls[0] as unknown as [string, RequestInit])[1].headers, + ) + expect(headers.get('x-opencode-session')).toBe('abc123') + expect(headers.get('user-agent')).toMatch(/^scn-chat\/1\.0/) + }) + + it('sends no session header when the admin set none', async () => { + const { providers } = await setupForTest(plugin(cocore)) + const fetch = vi.fn(async () => Response.json(completion)) + const model = providers[0]!.createModel({ + modelId: 'kimi', + session: 'abc123', + fetch: fetch as unknown as typeof globalThis.fetch, + }) + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + } as never) + const headers = new Headers( + (fetch.mock.calls[0] as unknown as [string, RequestInit])[1].headers, + ) + expect([...headers.keys()].some((name) => name.includes('session'))).toBe(false) + }) + it('lists models from the endpoint', async () => { const { providers } = await setupForTest(plugin(cocore)) const fetch = vi.fn(async () => Response.json({ data: [{ id: 'llama-3' }, { id: 'qwen' }] })) diff --git a/plugins/titles/src/index.ts b/plugins/titles/src/index.ts index eca24e4..3bbe55a 100644 --- a/plugins/titles/src/index.ts +++ b/plugins/titles/src/index.ts @@ -89,6 +89,7 @@ export default function titles(options: TitlesOptions = {}) { prompt: `User: ${textOf(turn.userMessage.record)}\n\nAssistant: ${textOf(turn.reply.record)}`, maxOutputTokens: OUTPUT_LIMIT, effort: 'none', + conversation: turn.conversation.uri, }) const title = cleanTitle(text) if (!title) diff --git a/plugins/titles/tests/index.test.ts b/plugins/titles/tests/index.test.ts index 50cdbb9..464c2f8 100644 --- a/plugins/titles/tests/index.test.ts +++ b/plugins/titles/tests/index.test.ts @@ -63,7 +63,10 @@ describe('titles plugin', () => { it('writes a generated title after the first completed reply', async () => { const { updateInfo, generateText } = await run(turn()) expect(generateText).toHaveBeenCalledWith( - expect.objectContaining({ model: { provider: 'fake', id: 'reply-model' } }), + expect.objectContaining({ + model: { provider: 'fake', id: 'reply-model' }, + conversation: 'at://did:plc:alice/space/c/1', + }), ) expect(updateInfo).toHaveBeenCalledWith( 'did:plc:alice', diff --git a/specs/plugins.md b/specs/plugins.md index ba1bb4b..e6f1b1d 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -128,6 +128,7 @@ Per-user settings are stored in a `plugin_user_settings` table: DID, plugin ID, - Plugin output that becomes a record goes through the context's storage methods, so it is always written under the user's DID. - `ModelProvider` gained an optional `providerOptions`, sent with every call to that provider. The OpenAI plugin needs `store: false` and encrypted reasoning on every request, and the interface had nowhere to say so. - `ModelAccess` carries an optional `fetch`, so a user endpoint can be reached through the private network guard. +- `ModelAccess` carries an optional `session`, stable for one conversation, and `GenerateTextRequest` an optional `conversation` to derive it from. OpenCode Go refuses requests without a session header, and nothing else told a provider which calls belong together. - `@scn-chat/plugin-api/testing` exports `setupForTest`, which runs a plugin's setup against a recording context, for plugin tests. - The per-user settings API is `GET /api/plugins/settings`, `PUT /api/plugins/:id/settings`, and `DELETE /api/plugins/:id/settings`. Secret values are never returned. A blank secret on save keeps the stored one. - An OpenAI-compatible plugin's ID is `openai-compatible-`, so the same package can be listed several times. diff --git a/specs/providers.md b/specs/providers.md index eb06324..7a23a0a 100644 --- a/specs/providers.md +++ b/specs/providers.md @@ -20,7 +20,7 @@ interface ModelProvider { name: string // shown in the UI userKeys: boolean // whether users may add their own API key userEndpoints?: boolean // whether users may add their own base URLs (OpenAI-compatible only) - createModel(args: { modelId: string; apiKey?: string; baseURL?: string }): LanguageModelV4 + createModel(args: { modelId: string; apiKey?: string; baseURL?: string; session?: string }): LanguageModelV4 listModels?(args: { apiKey?: string; baseURL?: string }): Promise replay: ReplayPolicy // how earlier reasoning is sent back, see below } @@ -34,7 +34,7 @@ interface ModelInfo { `LanguageModelV4` is the AI SDK's provider specification, from `@ai-sdk/provider`. That package is separate from the `ai` runtime and versions the interface explicitly. Returning a model that implements it, instead of our own streaming interface, means any AI SDK provider package, including community ones, can become an SCN Chat provider in a few lines. The rest of our interface (IDs, keys, listing, replay) stays ours. -`createModel` is called per request, since API keys can differ per user. The AI SDK factories are cheap to call. +`createModel` is called per request, since API keys can differ per user. The AI SDK factories are cheap to call. When the call belongs to a conversation, as a turn or a plugin's `generateText` naming one does, `session` is an ID that stays the same for that conversation: the first 32 hex characters of the SHA-256 of its URI, so a provider can keep a conversation's calls together without learning the user's DID. ### Phase 1 provider plugins @@ -45,7 +45,7 @@ interface ModelInfo { | `@scn-chat/plugin-google` | `@ai-sdk/google` | `createGoogle({ apiKey })` | | `@scn-chat/plugin-openai-compatible` | `@ai-sdk/openai-compatible` | Takes `id`, `name`, `baseURL`, and optional `apiKey`. Can be listed several times with different IDs. Sets `includeUsage: true` | -Each plugin takes an optional admin `apiKey`, marked secret, and `userKeys` (default `true`) as options, and exports a zod `optionsSchema` for them. The Anthropic, OpenAI, and Google plugins list their models from each API's model list endpoint, with every capability off for the admin to set. The OpenAI-compatible plugin also takes `userEndpoints` (default `false`) and `allowPrivateNetworks` (default `false`). +Each plugin takes an optional admin `apiKey`, marked secret, and `userKeys` (default `true`) as options, and exports a zod `optionsSchema` for them. The Anthropic, OpenAI, and Google plugins list their models from each API's model list endpoint, with every capability off for the admin to set. The OpenAI-compatible plugin also takes `userEndpoints` (default `false`), `allowPrivateNetworks` (default `false`), and two optional headers for endpoints that require them. `sessionHeader` names a header that carries each call's `session`, such as `x-opencode-session`, which OpenCode Go refuses requests without. `userAgent` replaces the user agent, for endpoints that ask clients to name themselves. Both apply to the endpoint's model list too, and to users' own endpoints under the instance. ### Models @@ -149,6 +149,8 @@ The provider data is stored in the `providerData` field of reasoning, text, and - [ ] A value encrypted with a key in `SECRET_KEYS_OLD` still decrypts, and new values use `SECRET_KEY`. - [ ] A credential whose key no current or retired key can decrypt is listed with `keyUnreadable`, and a turn using it ends with an error asking the user to add the key again. - [ ] A user endpoint's model references use the user's slug. +- [ ] A model resolved for a conversation gets a session ID that is the same for every call in that conversation and doesn't contain the user's DID. +- [ ] An OpenAI-compatible instance with `sessionHeader` sends the session in that header, and one without it sends no session header. - [ ] Each lexicon effort value maps to the AI SDK reasoning value in the table. - [ ] Reasoning from a different provider than the current turn's is not replayed. - [ ] `GET /api/models` lists the admin models the user may use plus the models from the user's credentials, and returns the admin default only when the user may use it. diff --git a/specs/titles.md b/specs/titles.md index 9f1ee86..48ec278 100644 --- a/specs/titles.md +++ b/specs/titles.md @@ -37,7 +37,7 @@ The turn context from the chat-turns spec carries the conversation info and the ### Generating -The handler calls `ctx.models.generateText` for the user. The system prompt asks for a title of at most `maxWords` words, with no quotes and no trailing punctuation, in the language of the conversation. The input is the text parts of the triggering user message and the reply, each cut to 2,000 characters. Reasoning, tool calls, and attachments are left out. The call sets effort to `none`, so a reasoning model spends its output on the title, and allows 1,000 output tokens for models that reason anyway. +The handler calls `ctx.models.generateText` for the user. The system prompt asks for a title of at most `maxWords` words, with no quotes and no trailing punctuation, in the language of the conversation. The input is the text parts of the triggering user message and the reply, each cut to 2,000 characters. Reasoning, tool calls, and attachments are left out. The call names the conversation, so endpoints that group calls by session see it with the conversation's turns. It sets effort to `none`, so a reasoning model spends its output on the title, and allows 1,000 output tokens for models that reason anyway. The output is cleaned up before writing. Whitespace is collapsed, surrounding quotes and trailing periods are removed, and the result is cut to the lexicon's 300 graphemes. An empty result is treated as a failure.