From 2e5fcc2bf95a740bfc1f21287fd9bb0b9ee68333 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:34:41 +0200 Subject: [PATCH 1/6] refactor: read an endpoint's model list in one place The Claude and DeepSeek drivers each read the models of the endpoint they are pointed at from its /v1/models, with two parsers that knew different halves of what endpoints answer. That endpoint can be a provider's own API or a gateway in front of several; the list is the same either way. sdk/providerModels.ts now holds the one reader: where the list lives, the shapes it comes in (data, models or a bare array; claude-code-router's encoded ids and 1M markers), the upstream prefix of a routed id, context sizes, and a phone-sized cap. Claude keeps only its 1M bookkeeping on top; DeepSeek's catalog module is now catalog.ts. Co-Authored-By: Claude Code --- .../claude/__tests__/facadeModels.test.ts | 50 +++-- .../agent-host/src/drivers/claude/facade.ts | 141 ++++-------- ...ateway.test.ts => deepseekCatalog.test.ts} | 53 ++--- .../src/drivers/deepseek/catalog.ts | 127 +++++++++++ .../agent-host/src/drivers/deepseek/driver.ts | 23 +- .../src/drivers/deepseek/gateway.ts | 200 ------------------ .../src/sdk/__tests__/providerModels.test.ts | 141 ++++++++++++ packages/agent-host/src/sdk/providerModels.ts | 186 ++++++++++++++++ 8 files changed, 551 insertions(+), 370 deletions(-) rename packages/agent-host/src/drivers/deepseek/__tests__/{deepseekGateway.test.ts => deepseekCatalog.test.ts} (59%) create mode 100644 packages/agent-host/src/drivers/deepseek/catalog.ts delete mode 100644 packages/agent-host/src/drivers/deepseek/gateway.ts create mode 100644 packages/agent-host/src/sdk/__tests__/providerModels.test.ts create mode 100644 packages/agent-host/src/sdk/providerModels.ts diff --git a/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts b/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts index 2121bf19..1b97e85f 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts @@ -79,9 +79,10 @@ import { fetchGatewayModels, isProviderBoundSession, modelSupports1mContext, - parseGatewayModels, + toGatewayModels, type SdkSessionOptions, } from '../facade'; +import { parseProviderModels } from '../../../sdk/providerModels'; function baseOpts(over: Partial = {}): SdkSessionOptions { return { @@ -443,8 +444,8 @@ describe('fetchGatewayModels', () => { global.fetch = (async (url: string, init?: RequestInit) => { calls.push([url, init]); return { - ok: true, - json: async () => ({ + status: 200, + text: async () => JSON.stringify({ // Real shape from a claude-code-router instance: some entries // carry display_name, some don't. data: [ @@ -460,7 +461,7 @@ describe('fetchGatewayModels', () => { expect(calls).toHaveLength(1); const [url, init] = calls[0]!; expect(url).toBe('http://router.example:3458/v1/models'); // trailing slash on the base URL stripped - expect((init?.headers as Record).Authorization).toBe('Bearer tok-123'); + expect((init?.headers as Record).authorization).toBe('Bearer tok-123'); expect(models).toEqual([ { id: 'Claude Code API/claude-sonnet-5', label: 'Claude Sonnet 5', provider: 'Claude Code API' }, { id: 'Z.ai (Global) - Coding Plan/glm-5.2', label: 'glm-5.2', provider: 'Z.ai (Global) - Coding Plan' }, @@ -472,10 +473,10 @@ describe('fetchGatewayModels', () => { process.env.ANTHROPIC_API_KEY = 'k'; let userAgent: string | undefined; global.fetch = (async (_url: string, init?: RequestInit) => { - userAgent = (init?.headers as Record)['User-Agent']; + userAgent = (init?.headers as Record)['user-agent']; return { - ok: true, - json: async () => ({ + status: 200, + text: async () => JSON.stringify({ data: [ { id: `anthropic/claude-ccr-h${hex('Gateway Test/glm-9-flash')}[1m]`, display_name: 'Gateway Test/GLM-9-Flash (1M context)', max_input_tokens: 1_310_720 }, { id: `anthropic/claude-ccr-h${hex('Gateway Test/claude-opus-9')}`, display_name: 'Gateway Test/Claude Opus 9', max_input_tokens: 200_000 }, @@ -501,8 +502,8 @@ describe('fetchGatewayModels', () => { process.env.ANTHROPIC_API_KEY = 'api-key'; let authHeader: string | undefined; global.fetch = (async (_url: string, init?: RequestInit) => { - authHeader = (init?.headers as Record).Authorization; - return { ok: true, json: async () => ({ data: [] }) }; + authHeader = (init?.headers as Record).authorization; + return { status: 200, text: async () => JSON.stringify({ data: [] }) }; }) as unknown as typeof fetch; await fetchGatewayModels(); @@ -512,7 +513,7 @@ describe('fetchGatewayModels', () => { it('returns [] on a non-ok response rather than throwing', async () => { process.env.ANTHROPIC_BASE_URL = 'http://router.example'; process.env.ANTHROPIC_API_KEY = 'k'; - global.fetch = (async () => ({ ok: false, status: 401, statusText: 'Unauthorized' })) as unknown as typeof fetch; + global.fetch = (async () => ({ status: 401, text: async () => '' })) as unknown as typeof fetch; expect(await fetchGatewayModels()).toEqual([]); }); @@ -528,7 +529,7 @@ describe('fetchGatewayModels', () => { it('returns [] when the response has no "data" array', async () => { process.env.ANTHROPIC_BASE_URL = 'http://router.example'; process.env.ANTHROPIC_API_KEY = 'k'; - global.fetch = (async () => ({ ok: true, json: async () => ({ unexpected: 'shape' }) })) as unknown as typeof fetch; + global.fetch = (async () => ({ status: 200, text: async () => JSON.stringify({ unexpected: 'shape' }) })) as unknown as typeof fetch; expect(await fetchGatewayModels()).toEqual([]); }); @@ -536,10 +537,12 @@ describe('fetchGatewayModels', () => { const hex = (text: string): string => Buffer.from(text, 'utf8').toString('hex'); -describe('parseGatewayModels', () => { +describe('toGatewayModels', () => { + const parse = (body: unknown) => toGatewayModels(parseProviderModels(body)); + it('decodes claude-code-router ids, drops the provider from labels, and reads each context window', () => { // The Anthropic-shaped list claude-code-router sends a Claude Code client. - const parsed = parseGatewayModels({ + const parsed = parse({ data: [ { id: `anthropic/claude-ccr-h${hex('Z.ai (Global) - Coding Plan/glm-5.3-flash')}[1m]`, @@ -557,31 +560,26 @@ describe('parseGatewayModels', () => { { id: `anthropic/claude-ccr-h${hex('Golem/local-model')}`, display_name: 'Golem/local-model', max_input_tokens: 0 }, ], }); - expect(parsed?.models).toEqual([ + expect(parsed.models).toEqual([ { id: 'Z.ai (Global) - Coding Plan/glm-5.3-flash', label: 'GLM-5.3-Flash (1M context)', provider: 'Z.ai (Global) - Coding Plan' }, { id: 'Z.ai (Global) - Coding Plan/glm-4.7-flash', label: 'GLM-4.7-Flash', provider: 'Z.ai (Global) - Coding Plan' }, { id: 'Golem/local-model', label: 'local-model', provider: 'Golem' }, ]); - expect([...parsed!.oneMillion]).toEqual([ + expect([...parsed.oneMillion]).toEqual([ ['Z.ai (Global) - Coding Plan/glm-5.3-flash', true], ['Z.ai (Global) - Coding Plan/glm-4.7-flash', false], ]); }); it('takes a plain list as it is, saying nothing about context', () => { - const parsed = parseGatewayModels({ data: [{ id: 'claude-sonnet-5', display_name: 'Claude Sonnet 5' }, { id: 7 }] }); - expect(parsed?.models).toEqual([{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }]); - expect(parsed?.oneMillion.size).toBe(0); + const parsed = parse({ data: [{ id: 'claude-sonnet-5', display_name: 'Claude Sonnet 5' }, { id: 7 }] }); + expect(parsed.models).toEqual([{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }]); + expect(parsed.oneMillion.size).toBe(0); }); it('lists a model once when it comes both with and without the [1m] marker', () => { - const parsed = parseGatewayModels({ data: [{ id: 'claude-opus-5[1m]' }, { id: 'claude-opus-5' }] }); - expect(parsed?.models).toEqual([{ id: 'claude-opus-5', label: 'claude-opus-5' }]); - expect(parsed?.oneMillion.get('claude-opus-5')).toBe(true); - }); - - it('is null without a data list', () => { - expect(parseGatewayModels({ unexpected: 'shape' })).toBeNull(); - expect(parseGatewayModels(null)).toBeNull(); + const parsed = parse({ data: [{ id: 'claude-opus-5[1m]' }, { id: 'claude-opus-5' }] }); + expect(parsed.models).toEqual([{ id: 'claude-opus-5', label: 'claude-opus-5' }]); + expect(parsed.oneMillion.get('claude-opus-5')).toBe(true); }); }); diff --git a/packages/agent-host/src/drivers/claude/facade.ts b/packages/agent-host/src/drivers/claude/facade.ts index f31b6edd..9b871887 100644 --- a/packages/agent-host/src/drivers/claude/facade.ts +++ b/packages/agent-host/src/drivers/claude/facade.ts @@ -19,6 +19,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { findInDirs, findOnPath, isFile } from '../../sdk/executable'; +import type { HttpGet } from '../../sdk/net'; +import { fetchProviderModels, type EndpointModel } from '../../sdk/providerModels'; import type { CanUseTool, Options, @@ -285,7 +287,7 @@ export const DISCOVERY_CACHE_MS = 10 * 60_000; * such a client in Anthropic's own `/v1/models` shape, which says how large * each model's context window is (when the router knows), and anyone else * with a plain list that does not. What it says about the 1M window is kept - * for `modelSupports1mContext`; see `parseGatewayModels` for both shapes. + * for `modelSupports1mContext`; see `toGatewayModels`. */ export async function fetchGatewayModels(): Promise { const baseUrl = process.env.ANTHROPIC_BASE_URL; @@ -294,42 +296,33 @@ export async function fetchGatewayModels(): Promise { console.error('[SdkFacade] fetchGatewayModels: ANTHROPIC_BASE_URL or an auth token is not set'); return []; } - - try { - const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/v1/models`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'anthropic-version': '2023-06-01', - 'User-Agent': GATEWAY_USER_AGENT, - }, - // Generous relative to SUPPORTED_MODELS_TIMEOUT_MS: a cold gateway - // enumerating several upstream providers is slower than one live CLI - // answering a control request. - signal: AbortSignal.timeout(5_000), - }); - if (!response.ok) { - console.error(`[SdkFacade] fetchGatewayModels: gateway returned ${response.status} ${response.statusText}`); - return []; - } - - const parsed = parseGatewayModels(await response.json()); - if (!parsed) { - console.error('[SdkFacade] fetchGatewayModels: response has no "data" array'); - return []; - } - for (const [id, oneMillion] of parsed.oneMillion) gatewayOneMillion.set(contextKey(id), oneMillion); - return parsed.models; - } catch (err) { - console.error('[SdkFacade] fetchGatewayModels: request failed:', err); - return []; - } + const models = await fetchProviderModels(baseUrl, { + token, + headers: { 'anthropic-version': '2023-06-01', 'user-agent': GATEWAY_USER_AGENT }, + httpGet: gatewayGet, + log: (line) => console.error(line), + tag: '[SdkFacade] fetchGatewayModels:', + }); + if (!models) return []; + const parsed = toGatewayModels(models); + for (const [id, oneMillion] of parsed.oneMillion) gatewayOneMillion.set(contextKey(id), oneMillion); + return parsed.models; } /** Any `claude-code/…` agent gets claude-code-router's Anthropic-shaped * model list, the one that carries context-window sizes. */ const GATEWAY_USER_AGENT = 'claude-code/1.0 (codedeck)'; +/** The gateway's list, read with a shorter timeout than the host's other + * calls: a phone is waiting on it. Generous next to + * SUPPORTED_MODELS_TIMEOUT_MS all the same — a cold gateway enumerating + * several upstream providers is slower than one live CLI answering a + * control request. */ +const gatewayGet: HttpGet = async (url, headers) => { + const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(5_000) }); + return { status: res.status, text: await res.text() }; +}; + /** A model id compared without its `[1m]` marker or case. */ const contextKey = (id: string): string => id.replace(/\[1m\]$/i, '').toLowerCase(); @@ -337,79 +330,35 @@ const contextKey = (id: string): string => id.replace(/\[1m\]$/i, '').toLowerCas * itself last said (by `contextKey`). Filled by `fetchGatewayModels`. */ const gatewayOneMillion = new Map(); -/** claude-code-router's id for a model as it lists it to Claude Code: - * `anthropic/claude-ccr-h`, the hex being the router's own - * `/` id in UTF-8. */ -const CCR_ENCODED_ID = /^(?:anthropic\/)?claude-ccr-h((?:[0-9a-f]{2})+)$/i; - /** A window at least this large is the 1M tier (a provider's "1M" is not * always exactly a million: 1 048 576, 1 050 000 and 1 310 720 all occur). */ const ONE_MILLION_CONTEXT = 1_000_000; -interface GatewayModelEntry { - id: string; - display_name?: unknown; - max_input_tokens?: unknown; - capabilities?: { context_window?: { supports_1m_context?: unknown; max_input_tokens?: unknown } }; -} - -/** Whether a gateway entry says it has the 1M window: its explicit flag, - * else a `[1m]` id, else its input-token limit. Undefined when it says - * nothing (a plain list, or a size the gateway does not know, sent as 0). */ -function entryOneMillion(m: GatewayModelEntry): boolean | undefined { - const window = m.capabilities?.context_window; - if (typeof window?.supports_1m_context === 'boolean') return window.supports_1m_context; - if (/\[1m\]$/i.test(m.id)) return true; - const max = [m.max_input_tokens, window?.max_input_tokens].find((n): n is number => typeof n === 'number' && n > 0); - return max === undefined ? undefined : max >= ONE_MILLION_CONTEXT; -} - /** - * A gateway's `/v1/models` body as the phone's model list, plus what each - * model's entry says about the 1M window. Null when there is no `data` list. + * A gateway's models (`sdk/providerModels`) as the phone's model list, plus + * what each says about the 1M window: its own word when it gives one (a flag, + * or a `[1m]` id), else its input-token limit; nothing for a plain list. * - * Two shapes arrive. claude-code-router's plain list has the router's ids - * (`Z.ai (Global) - Coding Plan/glm-5.3-flash`) and nothing on context. Its - * Anthropic-shaped list, sent to a Claude Code client, encodes each id - * (`CCR_ENCODED_ID`), marks a 1M model's id with `[1m]` and its - * `display_name` with "(1M context)", and gives the window's size. The listed - * id is always the router's own, decoded and without the marker: it is what - * a session sends, and what the phone keeps as a machine's default. The label - * drops the provider prefix, which the phone shows on its own. + * claude-code-router answers a Claude Code client with an Anthropic-shaped + * list whose ids it encodes and whose 1M models it marks; the shared parser + * decodes both, so the id here is always the router's own — what a session + * sends, and what the phone keeps as a machine's default. A model without a + * name of its own is labelled by the part after its provider, which the + * phone shows on its own. */ -export function parseGatewayModels( - body: unknown, -): { models: SdkModelDescriptor[]; oneMillion: Map } | null { - const list = (body as { data?: unknown } | null)?.data; - if (!Array.isArray(list)) return null; - const models: SdkModelDescriptor[] = []; +export function toGatewayModels(models: EndpointModel[]): { models: SdkModelDescriptor[]; oneMillion: Map } { const oneMillion = new Map(); - for (const m of list) { - if (typeof (m as { id?: unknown })?.id !== 'string') continue; - const entry = m as GatewayModelEntry; - const bare = entry.id.replace(/\[1m\]$/i, ''); - const hex = CCR_ENCODED_ID.exec(bare)?.[1]; - const id = (hex && Buffer.from(hex, 'hex').toString('utf8')) || bare; - if (models.some((known) => known.id === id)) continue; - // A router-prefixed id ("Claude Code API/claude-sonnet-5") names its - // channel before the first "/"; the model after it may hold a "/" of - // its own. The same model can come through several channels, so the - // channel is kept as the provider, and a label without display_name - // is the model part rather than the raw id. - const slash = id.indexOf('/'); - const provider = slash > 0 ? id.slice(0, slash) : undefined; - const named = typeof entry.display_name === 'string' && provider && entry.display_name.startsWith(`${provider}/`) - ? entry.display_name.slice(provider.length + 1) - : entry.display_name; - models.push({ - id, - label: (typeof named === 'string' && named) || (provider ? id.slice(slash + 1) : id), - ...(provider ? { provider } : {}), - }); - const said = entryOneMillion(entry); - if (said !== undefined) oneMillion.set(id, said); - } - return { models, oneMillion }; + const list = models.map((m) => { + const said = + m.oneMillionContext ?? (m.contextWindow !== undefined ? m.contextWindow >= ONE_MILLION_CONTEXT : undefined); + if (said !== undefined) oneMillion.set(m.id, said); + return { + id: m.id, + label: m.label ?? (m.provider ? m.id.slice(m.provider.length + 1) : m.id), + ...(m.provider ? { provider: m.provider } : {}), + }; + }); + return { models: list, oneMillion }; } /** diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts similarity index 59% rename from packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts rename to packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts index d82ab03e..5767aa8e 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts @@ -1,5 +1,5 @@ /** - * A gateway the harness is pointed at: reading the models it serves, and + * The endpoint the harness is pointed at: reading the models it serves, and * writing them into the harness's own profile as its catalog — beside the * block the MCP list owns, since both live in that one file. */ @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { DeepSeekMcp } from '../mcp'; -import { fetchGatewayCatalog, gatewayModelsUrl, parseModels, renderCatalogLayer, syncGatewayCatalog } from '../gateway'; +import { fetchEndpointCatalog, renderCatalogLayer, syncEndpointCatalog } from '../catalog'; function profile(initial = '# Your patch layer for this dsh profile.\n[]\n'): string { const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-gateway-')); @@ -26,44 +26,23 @@ const answering = (body: unknown, status = 200, expectUrl?: string, expectKey?: return { status, text: typeof body === 'string' ? body : JSON.stringify(body) }; }); -describe('the model list a gateway serves', () => { - it('is read from the root the harness itself posts to', async () => { - expect(gatewayModelsUrl('http://gateway.example:3458')).toBe('http://gateway.example:3458/v1/models'); - expect(gatewayModelsUrl('http://gateway.example:3458/')).toBe('http://gateway.example:3458/v1/models'); - // The harness appends `/v1` unless the path already ends in it, and this - // mirrors that rule, so one setting configures both. - expect(gatewayModelsUrl('https://gateway.example/v1')).toBe('https://gateway.example/v1/models'); - }); - - it('reads the shapes gateways answer with, and keeps what says something', () => { - expect(parseModels({ data: [{ id: 'kimi-k2' }, { id: 'glm-4.6', context_length: 200_000 }] })).toEqual([ - { id: 'kimi-k2' }, - { id: 'glm-4.6', contextWindow: 200_000 }, - ]); - expect(parseModels(['a', 'b'])).toEqual([{ id: 'a' }, { id: 'b' }]); - expect(parseModels({ models: [{ id: 'x', name: 'X' }] })).toEqual([{ id: 'x', name: 'X' }]); - // Duplicates, blanks and anything that is not a model are dropped. - expect(parseModels({ data: [{ id: 'a' }, { id: 'a' }, { id: ' ' }, { no: 'id' }, 7] })).toEqual([{ id: 'a' }]); - expect(parseModels({ error: 'nope' })).toEqual([]); - expect(parseModels('not json at all')).toEqual([]); - }); - +describe('the model list the endpoint serves', () => { it('is asked with the key, and answered with the endpoint as the harness reads it', async () => { const httpGet = answering({ data: [{ id: 'kimi-k2' }] }, 200, 'http://gw.example/v1/models', 'sk-1'); - const catalog = await fetchGatewayCatalog('http://gw.example/', 'sk-1', httpGet, () => {}); + const catalog = await fetchEndpointCatalog('http://gw.example/', 'sk-1', httpGet, () => {}); expect(catalog).toEqual({ baseUrl: 'http://gw.example', models: [{ id: 'kimi-k2' }], defaultModel: 'kimi-k2' }); }); - it('answers nothing — and says so — when the gateway refuses, breaks or lists nothing', async () => { + it('answers nothing — and says so — when the endpoint refuses, breaks or lists nothing', async () => { const logs: string[] = []; const log = (line: string): void => { logs.push(line); }; - expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering({}, 401), log)).toBeUndefined(); - expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering('', 200), log)).toBeUndefined(); - expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering({ data: [] }, 200), log)).toBeUndefined(); + expect(await fetchEndpointCatalog('http://gw.example', 'sk', answering({}, 401), log)).toBeUndefined(); + expect(await fetchEndpointCatalog('http://gw.example', 'sk', answering('', 200), log)).toBeUndefined(); + expect(await fetchEndpointCatalog('http://gw.example', 'sk', answering({ data: [] }, 200), log)).toBeUndefined(); expect( - await fetchGatewayCatalog('http://gw.example', 'sk', async () => { + await fetchEndpointCatalog('http://gw.example', 'sk', async () => { throw new Error('ECONNREFUSED'); }, log), ).toBeUndefined(); @@ -100,7 +79,7 @@ describe('the catalog the harness reads', () => { it('is written beside the MCP block without disturbing it, and taken back out again', async () => { const dir = profile(); await new DeepSeekMcp({ profileDir: dir, log: () => {} }).act('add', [{ name: 'demo', setup: { type: 'stdio', command: '/usr/bin/demo' } }], []); - await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet: answering({ data: [{ id: 'kimi-k2' }] }) }, 'http://gw.example', 'sk-1'); + await syncEndpointCatalog({ profileDir: dir, log: () => {}, httpGet: answering({ data: [{ id: 'kimi-k2' }] }) }, 'http://gw.example', 'sk-1'); const both = layerOf(dir); expect(both).toMatch(/CodeDeck\+ MCP servers/); expect(both).toMatch(/serverName: demo/); @@ -112,16 +91,16 @@ describe('the catalog the harness reads', () => { { name: 'demo', transport: 'stdio', target: '/usr/bin/demo', enabled: true }, ]); - // No gateway any more: its block goes, the MCP one stays. - await syncGatewayCatalog({ profileDir: dir, log: () => {} }, undefined, undefined); + // No endpoint any more: its block goes, the MCP one stays. + await syncEndpointCatalog({ profileDir: dir, log: () => {} }, undefined, undefined); const after = layerOf(dir); expect(after).not.toMatch(/gateway catalog/); expect(after).toMatch(/serverName: demo/); }); - it('stays as it was when the gateway cannot be read', async () => { + it('stays as it was when the endpoint cannot be read', async () => { const dir = profile(); - const wrote = await syncGatewayCatalog({ profileDir: dir, log: () => {} }, 'http://gw.example', 'sk-1'); + const wrote = await syncEndpointCatalog({ profileDir: dir, log: () => {} }, 'http://gw.example', 'sk-1'); expect(wrote).toBe(false); expect(layerOf(dir)).toBe('# Your patch layer for this dsh profile.\n[]\n'); }); @@ -129,7 +108,7 @@ describe('the catalog the harness reads', () => { it('is written only when it changed', async () => { const dir = profile(); const httpGet = answering({ data: [{ id: 'kimi-k2' }] }); - expect(await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(true); - expect(await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(false); + expect(await syncEndpointCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(true); + expect(await syncEndpointCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(false); }); }); diff --git a/packages/agent-host/src/drivers/deepseek/catalog.ts b/packages/agent-host/src/drivers/deepseek/catalog.ts new file mode 100644 index 00000000..2523ff64 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/catalog.ts @@ -0,0 +1,127 @@ +/** + * The model catalog of the endpoint the harness is pointed at. + * + * An operator who sets `DEEPSEEK_BASE_URL` is saying "run sessions on this + * endpoint" — another provider's API, or a gateway in front of several. The + * harness takes that endpoint from the environment (its DeepSeek route reads + * exactly that variable), but its *model catalog* is its own, fixed list of + * DeepSeek model names — so an endpoint with other models would be sent a + * name it does not know, and the phone would offer models it cannot serve. + * What the endpoint serves is the one thing only it can say, at its + * `/v1/models` (`sdk/providerModels`), on the same root the harness itself + * appends `/v1` to and then posts `/messages` to. + * + * So this module asks the endpoint for that list and writes it into the + * harness's own profile, as the deployment's catalog for the native route: + * a patch row over the `llm-deepseek` entry, which is how the harness + * documents its catalog as replaceable. From the next harness start, the + * phone offers exactly what the endpoint serves and a session can select any + * of it. No endpoint configured (or one that does not answer) means no row + * at all: the harness's own catalog stands, and nothing is guessed. + */ +import * as path from 'node:path'; +import { dump } from 'js-yaml'; +import type { HttpGet } from '../../sdk/net'; +import { fetchProviderModels, type EndpointModel } from '../../sdk/providerModels'; +import { ProfileLayer, type LayerBlock } from './profileLayer'; + +/** Our block in the profile's patch layer. Its marker text is what finds the + * block again in a profile written by an earlier version, so it stays as + * it was first written. */ +const BLOCK: LayerBlock = { + begin: "# --- CodeDeck+ gateway catalog: written from the gateway's own model list; everything outside this block is yours ---", + end: '# --- end CodeDeck+ gateway catalog ---', +}; + +/** The entry that mounts the native DeepSeek adapter; its config carries the + * endpoint and the catalog. */ +const PROVIDER_ROW = 'llm-deepseek'; +/** The entries naming the provider and model a session starts on. They have + * to move with the catalog: the harness always offers the model it is on, + * so a default the endpoint does not serve would appear as one nobody can + * run. `acp` is the profile's own selection (what a new session starts + * with); `agent-default-model` is the agent module's. */ +const SESSION_MODEL_ROW = 'acp'; +const DEFAULT_MODEL_ROW = 'agent-default-model'; +/** The provider the native route is registered under. */ +const NATIVE_PROVIDER = 'deepseek-official'; + +export interface EndpointCatalog { + /** The endpoint as the harness should read it. */ + baseUrl: string; + models: EndpointModel[]; + /** The model sessions start on: the endpoint's own first entry, in the + * order it lists them. */ + defaultModel: string; +} + +/** + * The catalog the endpoint serves, or `undefined` when it could not be read + * (see `fetchProviderModels`). + */ +export async function fetchEndpointCatalog( + baseUrl: string, + key: string | undefined, + httpGet: HttpGet, + log: (message: string) => void, +): Promise { + const models = await fetchProviderModels(baseUrl, { ...(key ? { token: key } : {}), httpGet, log, tag: '[deepseek]' }); + if (!models) { + log(`[deepseek] leaving the harness's own catalog in place`); + return undefined; + } + return { baseUrl: baseUrl.replace(/\/+$/, ''), models, defaultModel: models[0]!.id }; +} + +/** + * The patch rows for an endpoint's catalog: the catalog itself, and the + * model a session starts on. + * + * Never the endpoint. The profile is shared by every harness process, and + * the route takes a `baseURL` in its config over `DEEPSEEK_BASE_URL` in the + * environment — so an endpoint written here would also be where a session + * bound to a provider profile sent that profile's token, whatever its own + * environment named. The operator's process finds the endpoint in the + * environment it already has. + */ +export function renderCatalogLayer(catalog: EndpointCatalog): string { + const models = catalog.models.map((model) => ({ + id: model.id, + ...(model.label !== undefined ? { name: model.label } : {}), + ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}), + })); + const selection = { provider: NATIVE_PROVIDER, model: catalog.defaultModel }; + const rows = [ + { id: PROVIDER_ROW, config: { models } }, + { id: SESSION_MODEL_ROW, config: selection }, + { id: DEFAULT_MODEL_ROW, config: selection }, + ]; + return dump(rows, { lineWidth: -1, noRefs: true, quotingType: "'" }).trimEnd(); +} + +export interface CatalogSyncOptions { + /** The profile directory (`$DSH_HOME/profiles/acp`). */ + profileDir: string; + log: (message: string) => void; + httpGet?: HttpGet; +} + +/** + * Write the endpoint's catalog into the harness profile (or take our row + * out when no endpoint is configured), and answer whether the layer changed. + */ +export async function syncEndpointCatalog( + options: CatalogSyncOptions, + baseUrl: string | undefined, + key: string | undefined, +): Promise { + const layer = new ProfileLayer(path.join(options.profileDir, 'cordis.patch.yml'), options.log); + if (!baseUrl || !options.httpGet) return layer.set(BLOCK, undefined); + const catalog = await fetchEndpointCatalog(baseUrl, key, options.httpGet, options.log); + if (!catalog) return false; + const changed = await layer.set(BLOCK, renderCatalogLayer(catalog)); + if (changed) { + options.log(`[deepseek] ${catalog.models.length} models from ${catalog.baseUrl} now make up the harness's catalog`); + } + return changed; +} diff --git a/packages/agent-host/src/drivers/deepseek/driver.ts b/packages/agent-host/src/drivers/deepseek/driver.ts index 2f99ebba..0a31ae9e 100644 --- a/packages/agent-host/src/drivers/deepseek/driver.ts +++ b/packages/agent-host/src/drivers/deepseek/driver.ts @@ -46,7 +46,8 @@ import { HARNESS_PLUGIN, installHarnessPlugin, QUESTION_MARKER } from './plugin' import { ASK_USER_TOOL, installProfileTools } from './profileTools'; import { parseQuestionLine, planReviewOf, toAnswerItems, toQuestionSpecs, type PlanReview, type PushedQuestionLine } from './questions'; import { deleteDshConversation } from './conversations'; -import { gatewayModelsUrl, syncGatewayCatalog } from './gateway'; +import { providerModelsUrl } from '../../sdk/providerModels'; +import { syncEndpointCatalog } from './catalog'; import { DSH_LABEL } from './install'; import { DeepSeekMcp } from './mcp'; import { DeepSeekPlugins, runDshPlugin, type DshRun } from './plugins'; @@ -851,7 +852,7 @@ export class DeepSeekDriver implements Driver { * start): the endpoint's catalog, and CodeDeck's own rows in its patch * layer. */ private async prepareProfile(): Promise { - await this.syncGateway(); + await this.syncCatalog(); await this.ownProfile(); } @@ -879,14 +880,14 @@ export class DeepSeekDriver implements Driver { /** * Point the harness at the operator's endpoint, with the models that - * endpoint serves (gateway.ts): without this a session on a gateway would - * be sent a DeepSeek model name the gateway does not know, and the phone + * endpoint serves (catalog.ts): without this a session on another + * endpoint would be sent a DeepSeek model name it does not know, and the phone * would offer models that are not there. */ - private async syncGateway(): Promise { + private async syncCatalog(): Promise { const env = this.options.baseEnv ?? process.env; try { - await syncGatewayCatalog( + await syncEndpointCatalog( { profileDir: dshProfileDir(this.options.home), log: this.options.log, ...(this.options.httpGet ? { httpGet: this.options.httpGet } : {}) }, env[DEEPSEEK_BASE_URL_ENV]?.trim() || undefined, env[DEEPSEEK_API_KEY_ENV]?.trim() || undefined, @@ -894,7 +895,7 @@ export class DeepSeekDriver implements Driver { } catch (error) { // The catalog is a convenience, never a reason to refuse to run: the // harness's own models still work. - this.options.log(`[deepseek] could not write the gateway's catalog: ${error instanceof Error ? error.message : String(error)}`); + this.options.log(`[deepseek] could not write the endpoint's catalog: ${error instanceof Error ? error.message : String(error)}`); } } @@ -988,9 +989,9 @@ export class DeepSeekDriver implements Driver { } /** - * Check the key against the endpoint it is for: the operator's gateway when - * one is configured (its `/models` is the one call every gateway has in - * common — the same one this driver reads the catalog from), else the + * Check the key against the endpoint it is for: the operator's endpoint + * when one is configured (its `/models` is the one call every endpoint has + * in common — the same one this driver reads the catalog from), else the * DeepSeek API. A refusal is a refusal; anything else says nothing. */ async checkCredential(credential: string, value: string): Promise { @@ -998,7 +999,7 @@ export class DeepSeekDriver implements Driver { const baseEnv = this.options.baseEnv ?? process.env; const base = baseEnv[DEEPSEEK_BASE_URL_ENV]?.trim(); try { - const res = await this.options.httpGet(base ? gatewayModelsUrl(base) : 'https://api.deepseek.com/models', { + const res = await this.options.httpGet(base ? providerModelsUrl(base) : 'https://api.deepseek.com/models', { authorization: `Bearer ${value}`, }); return res.status !== 401 && res.status !== 403; diff --git a/packages/agent-host/src/drivers/deepseek/gateway.ts b/packages/agent-host/src/drivers/deepseek/gateway.ts deleted file mode 100644 index 1b6d346a..00000000 --- a/packages/agent-host/src/drivers/deepseek/gateway.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * A gateway the harness is pointed at, end to end. - * - * An operator who sets `DEEPSEEK_BASE_URL` is saying "run sessions on this - * endpoint". The harness takes that endpoint from the environment (its - * DeepSeek route reads exactly that variable), but its *model catalog* is its - * own, fixed list of DeepSeek model names — so a gateway with its own models - * would be sent a name it does not know, and the phone would offer models the - * gateway cannot serve. What the gateway serves is the one thing only the - * gateway can say, and it says it at `/models` (the OpenAI-shaped list every - * gateway exposes), on the same root the harness itself appends `/v1` to and - * then posts `/messages` to. - * - * So this module asks the gateway for that list and writes it into the - * harness's own profile, as the deployment's catalog for the native route: - * a patch row over the `llm-deepseek` entry, which is how the harness - * documents its catalog as replaceable. From the next harness start, the - * phone offers exactly what the gateway serves and a session can select any - * of it. No gateway configured (or one that does not answer) means no row at - * all: the harness's own catalog stands, and nothing is guessed. - */ -import * as path from 'node:path'; -import { dump } from 'js-yaml'; -import type { HttpGet } from '../../sdk/net'; -import { ProfileLayer, type LayerBlock } from './profileLayer'; - -/** Our block in the profile's patch layer. */ -const BLOCK: LayerBlock = { - begin: "# --- CodeDeck+ gateway catalog: written from the gateway's own model list; everything outside this block is yours ---", - end: '# --- end CodeDeck+ gateway catalog ---', -}; - -/** The entry that mounts the native DeepSeek adapter; its config carries the - * endpoint and the catalog. */ -const PROVIDER_ROW = 'llm-deepseek'; -/** The entries naming the provider and model a session starts on. They have - * to move with the catalog: the harness always offers the model it is on, - * so a default the gateway does not serve would appear as one nobody can - * run. `acp` is the profile's own selection (what a new session starts - * with); `agent-default-model` is the agent module's. */ -const SESSION_MODEL_ROW = 'acp'; -const DEFAULT_MODEL_ROW = 'agent-default-model'; -/** The provider the native route is registered under. */ -const NATIVE_PROVIDER = 'deepseek-official'; - -/** How many of a gateway's models are kept: a catalog is for a phone screen. */ -const MAX_MODELS = 200; - -/** One model, as a catalog entry. */ -export interface GatewayModel { - id: string; - name?: string; - contextWindow?: number; -} - -export interface GatewayCatalog { - /** The endpoint as the harness should read it. */ - baseUrl: string; - models: GatewayModel[]; - /** The model sessions start on: the gateway's own first entry, in the - * order it lists them. */ - defaultModel: string; -} - -/** - * The list a gateway serves, or `undefined` when it could not be read (an - * unreachable endpoint, a refusal, an answer that is not a model list). The - * base URL is normalised the way the harness normalises it, so the same - * variable points at the same endpoint for both. - */ -export async function fetchGatewayCatalog( - baseUrl: string, - key: string | undefined, - httpGet: HttpGet, - log: (message: string) => void, -): Promise { - const url = gatewayModelsUrl(baseUrl); - let body: unknown; - try { - const response = await httpGet(url, key ? { authorization: `Bearer ${key}` } : {}); - if (response.status < 200 || response.status >= 300) { - log(`[deepseek] the gateway at ${baseUrl} answered ${response.status} for its model list`); - return undefined; - } - body = readJson(response); - } catch (error) { - log(`[deepseek] could not read the gateway's model list from ${url}: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - const models = parseModels(body); - if (models.length === 0) { - log(`[deepseek] the gateway at ${baseUrl} listed no models; leaving the harness's own catalog in place`); - return undefined; - } - return { baseUrl: baseUrl.replace(/\/+$/, ''), models, defaultModel: models[0]!.id }; -} - -/** The harness's own rule for where a provider's list of models lives: the - * root ends in `/v1` (the endpoint `/messages` is posted to lives beside - * it). Mirrored here so one setting configures both. */ -export function gatewayModelsUrl(baseUrl: string): string { - const base = baseUrl.replace(/\/+$/, ''); - const rooted = new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`; - return `${rooted}/models`; -} - -/** The model ids in a gateway's answer: the OpenAI-shaped `data` array, a - * bare array, or a `models` array. Anything else yields nothing. */ -export function parseModels(body: unknown): GatewayModel[] { - const list = Array.isArray(body) - ? body - : typeof body === 'object' && body !== null - ? ((body as { data?: unknown }).data ?? (body as { models?: unknown }).models) - : undefined; - if (!Array.isArray(list)) return []; - const models: GatewayModel[] = []; - const seen = new Set(); - for (const entry of list) { - const id = - typeof entry === 'string' - ? entry - : typeof entry === 'object' && entry !== null - ? (entry as { id?: unknown; name?: unknown }).id - : undefined; - if (typeof id !== 'string' || id.trim() === '' || seen.has(id)) continue; - seen.add(id); - const context = typeof entry === 'object' && entry !== null ? contextWindowOf(entry as Record) : undefined; - const name = typeof entry === 'object' && entry !== null && typeof (entry as { name?: unknown }).name === 'string' ? (entry as { name: string }).name : undefined; - models.push({ id, ...(name && name !== id ? { name } : {}), ...(context !== undefined ? { contextWindow: context } : {}) }); - if (models.length >= MAX_MODELS) break; - } - return models; -} - -/** A model's context size, when the gateway states one (OpenAI-compatible - * gateways spell it several ways). */ -function contextWindowOf(entry: Record): number | undefined { - for (const field of ['context_window', 'context_length', 'contextWindow', 'max_input_tokens']) { - const value = entry[field]; - if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value; - } - return undefined; -} - -function readJson(response: Awaited>): unknown { - return JSON.parse(response.text ?? '') as unknown; -} - -/** - * The patch rows for a gateway's catalog: the catalog itself, and the model - * a session starts on. - * - * Never the endpoint. The profile is shared by every harness process, and - * the route takes a `baseURL` in its config over `DEEPSEEK_BASE_URL` in the - * environment — so an endpoint written here would also be where a session - * bound to a provider profile sent that profile's token, whatever its own - * environment named. The operator's process finds the gateway in the - * environment it already has. - */ -export function renderCatalogLayer(catalog: GatewayCatalog): string { - const models = catalog.models.map((model) => ({ - id: model.id, - ...(model.name !== undefined ? { name: model.name } : {}), - ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}), - })); - const selection = { provider: NATIVE_PROVIDER, model: catalog.defaultModel }; - const rows = [ - { id: PROVIDER_ROW, config: { models } }, - { id: SESSION_MODEL_ROW, config: selection }, - { id: DEFAULT_MODEL_ROW, config: selection }, - ]; - return dump(rows, { lineWidth: -1, noRefs: true, quotingType: "'" }).trimEnd(); -} - -export interface GatewaySyncOptions { - /** The profile directory (`$DSH_HOME/profiles/acp`). */ - profileDir: string; - log: (message: string) => void; - httpGet?: HttpGet; -} - -/** - * Write the gateway's catalog into the harness profile (or take our row out - * when no gateway is configured), and answer whether the layer changed. - */ -export async function syncGatewayCatalog( - options: GatewaySyncOptions, - baseUrl: string | undefined, - key: string | undefined, -): Promise { - const layer = new ProfileLayer(path.join(options.profileDir, 'cordis.patch.yml'), options.log); - if (!baseUrl || !options.httpGet) return layer.set(BLOCK, undefined); - const catalog = await fetchGatewayCatalog(baseUrl, key, options.httpGet, options.log); - if (!catalog) return false; - const changed = await layer.set(BLOCK, renderCatalogLayer(catalog)); - if (changed) { - options.log(`[deepseek] ${catalog.models.length} models from ${catalog.baseUrl} now make up the harness's catalog`); - } - return changed; -} diff --git a/packages/agent-host/src/sdk/__tests__/providerModels.test.ts b/packages/agent-host/src/sdk/__tests__/providerModels.test.ts new file mode 100644 index 00000000..668577a9 --- /dev/null +++ b/packages/agent-host/src/sdk/__tests__/providerModels.test.ts @@ -0,0 +1,141 @@ +/** + * Reading the models an endpoint serves — a provider's own API or a gateway + * in front of several — from its `/v1/models`. + */ +import { describe, expect, it, vi } from 'vitest'; +import { fetchProviderModels, MAX_ENDPOINT_MODELS, parseProviderModels, providerModelsUrl } from '../providerModels'; + +const hex = (text: string): string => Buffer.from(text, 'utf8').toString('hex'); + +/** An endpoint answering with `body`. */ +const answering = (body: unknown, status = 200) => + vi.fn(async (_url: string, _headers: Record) => ({ + status, + text: typeof body === 'string' ? body : JSON.stringify(body), + })); + +describe('providerModelsUrl', () => { + it('lists under /v1 of the root, unless the root already ends in it', () => { + expect(providerModelsUrl('http://gateway.example:3458')).toBe('http://gateway.example:3458/v1/models'); + expect(providerModelsUrl('http://gateway.example:3458/')).toBe('http://gateway.example:3458/v1/models'); + expect(providerModelsUrl('https://openrouter.ai/api')).toBe('https://openrouter.ai/api/v1/models'); + expect(providerModelsUrl('https://gateway.example/v1')).toBe('https://gateway.example/v1/models'); + }); +}); + +describe('parseProviderModels', () => { + it('reads the shapes endpoints answer with, keeping what says something', () => { + expect(parseProviderModels({ data: [{ id: 'kimi-k2' }, { id: 'glm-4.6', context_length: 200_000 }] })).toEqual([ + { id: 'kimi-k2' }, + { id: 'glm-4.6', contextWindow: 200_000 }, + ]); + expect(parseProviderModels(['a', 'b'])).toEqual([{ id: 'a' }, { id: 'b' }]); + expect(parseProviderModels({ models: [{ id: 'x', name: 'X' }] })).toEqual([{ id: 'x', label: 'X' }]); + // A name that only repeats the id says nothing. + expect(parseProviderModels({ data: [{ id: 'x', name: 'x' }] })).toEqual([{ id: 'x' }]); + }); + + it('drops duplicates, blanks and anything that is not a model', () => { + expect(parseProviderModels({ data: [{ id: 'a' }, { id: 'a' }, { id: ' ' }, { no: 'id' }, 7] })).toEqual([{ id: 'a' }]); + expect(parseProviderModels({ error: 'nope' })).toEqual([]); + expect(parseProviderModels('not a list')).toEqual([]); + expect(parseProviderModels(null)).toEqual([]); + }); + + it('keeps a routed id whole and names its upstream as the provider', () => { + expect( + parseProviderModels({ + data: [ + { id: 'Claude Code API/claude-sonnet-5', display_name: 'Claude Sonnet 5' }, + { id: 'Z.ai (Global) - Coding Plan/glm-5.2', display_name: 'Z.ai (Global) - Coding Plan/GLM-5.2' }, + { id: 'deepseek/deepseek-chat' }, + ], + }), + ).toEqual([ + { id: 'Claude Code API/claude-sonnet-5', label: 'Claude Sonnet 5', provider: 'Claude Code API' }, + { id: 'Z.ai (Global) - Coding Plan/glm-5.2', label: 'GLM-5.2', provider: 'Z.ai (Global) - Coding Plan' }, + { id: 'deepseek/deepseek-chat', provider: 'deepseek' }, + ]); + }); + + it("decodes claude-code-router's ids for Claude Code and reads what it says about context", () => { + expect( + parseProviderModels({ + data: [ + { + id: `anthropic/claude-ccr-h${hex('Z.ai (Global) - Coding Plan/glm-5.3-flash')}[1m]`, + display_name: 'Z.ai (Global) - Coding Plan/GLM-5.3-Flash (1M context)', + max_input_tokens: 1_310_720, + capabilities: { context_window: { max_input_tokens: 1_310_720, supports_1m_context: true } }, + }, + { + id: `anthropic/claude-ccr-h${hex('Golem/local-model')}`, + display_name: 'Golem/local-model', + // A size the router does not know. + max_input_tokens: 0, + }, + ], + }), + ).toEqual([ + { + id: 'Z.ai (Global) - Coding Plan/glm-5.3-flash', + label: 'GLM-5.3-Flash (1M context)', + provider: 'Z.ai (Global) - Coding Plan', + contextWindow: 1_310_720, + oneMillionContext: true, + }, + { id: 'Golem/local-model', label: 'local-model', provider: 'Golem' }, + ]); + }); + + it('lists a model once when it comes with and without the [1m] marker', () => { + expect(parseProviderModels({ data: [{ id: 'claude-opus-5' }, { id: 'claude-opus-5[1m]' }] })).toEqual([ + { id: 'claude-opus-5', oneMillionContext: true }, + ]); + }); + + it('keeps a phone-sized list', () => { + const many = Array.from({ length: MAX_ENDPOINT_MODELS + 5 }, (_, i) => ({ id: `m${i}` })); + expect(parseProviderModels({ data: many })).toHaveLength(MAX_ENDPOINT_MODELS); + }); +}); + +describe('fetchProviderModels', () => { + it('asks with the token and the extra headers', async () => { + const httpGet = answering({ data: [{ id: 'kimi-k2' }] }); + const models = await fetchProviderModels('https://gw.example/', { + token: 'sk-1', + headers: { 'user-agent': 'ua' }, + httpGet, + log: () => {}, + tag: '[t]', + }); + expect(models).toEqual([{ id: 'kimi-k2' }]); + expect(httpGet).toHaveBeenCalledWith('https://gw.example/v1/models', { 'user-agent': 'ua', authorization: 'Bearer sk-1' }); + }); + + it('answers nothing — and says why — when the endpoint refuses, breaks or lists nothing', async () => { + const logs: string[] = []; + const options = (httpGet: Parameters[1]['httpGet']) => ({ + httpGet, + log: (line: string) => logs.push(line), + tag: '[t]', + }); + expect(await fetchProviderModels('https://gw.example', options(answering({}, 401)))).toBeUndefined(); + expect(await fetchProviderModels('https://gw.example', options(answering('')))).toBeUndefined(); + expect(await fetchProviderModels('https://gw.example', options(answering({ data: [] })))).toBeUndefined(); + expect( + await fetchProviderModels( + 'https://gw.example', + options(async () => { + throw new Error('ECONNREFUSED'); + }), + ), + ).toBeUndefined(); + expect(await fetchProviderModels('not a url', options(answering({ data: [{ id: 'a' }] })))).toBeUndefined(); + expect(logs.some((line) => /^\[t\] .*answered 401/.test(line))).toBe(true); + expect(logs.some((line) => /listed no models/.test(line))).toBe(true); + expect(logs.some((line) => /ECONNREFUSED/.test(line))).toBe(true); + expect(logs.some((line) => /not a URL/.test(line))).toBe(true); + }); +}); diff --git a/packages/agent-host/src/sdk/providerModels.ts b/packages/agent-host/src/sdk/providerModels.ts new file mode 100644 index 00000000..0e952b9d --- /dev/null +++ b/packages/agent-host/src/sdk/providerModels.ts @@ -0,0 +1,186 @@ +/** + * The models an endpoint serves, read from its `/v1/models`. + * + * The endpoint is whatever an agent is pointed at: a provider's own API + * (DeepSeek, Moonshot, OpenRouter) or a gateway in front of several + * (claude-code-router, LiteLLM). Both answer the same OpenAI-shaped list, + * and only the endpoint can say what it serves — so drivers ask it rather + * than keep model names of their own. + * + * A gateway's ids commonly name the upstream before the model + * (`Z.ai (Global) - Coding Plan/glm-5.3-flash`): that prefix is the + * gateway's own routing key, needed verbatim on every request, so the id is + * kept whole and the prefix is reported as the model's `provider` for + * grouping only. A provider's own vendor-prefixed ids (OpenRouter's + * `deepseek/deepseek-chat`) read the same way. + */ +import type { HttpGet } from './net'; + +/** One model an endpoint serves. */ +export interface EndpointModel { + /** What a request names the model by — never rewritten. */ + id: string; + /** The endpoint's own name for it, without the `provider` prefix; absent + * when it gives none (or gives the id again). */ + label?: string; + /** The part of the id before its first `/`, when there is one. */ + provider?: string; + /** Input tokens the model accepts, when the endpoint says. */ + contextWindow?: number; + /** Whether it has the 1M-token window, when the endpoint says so in so + * many words (a flag, or the `[1m]` id marker) rather than by size. */ + oneMillionContext?: boolean; +} + +/** How many models are kept: the list is for a phone screen. */ +export const MAX_ENDPOINT_MODELS = 200; + +/** + * Where an endpoint lists its models: under `/v1` of its root, unless the + * root already ends in `/v1` (both spellings are common, and every agent + * that takes a base URL accepts one or the other). + */ +export function providerModelsUrl(baseUrl: string): string { + const base = baseUrl.replace(/\/+$/, ''); + const rooted = new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`; + return `${rooted}/models`; +} + +/** claude-code-router's id for a model as it lists it to Claude Code: + * `anthropic/claude-ccr-h`, the hex being the router's own + * `/` id in UTF-8. */ +const CCR_ENCODED_ID = /^(?:anthropic\/)?claude-ccr-h((?:[0-9a-f]{2})+)$/i; +/** The marker a 1M-context variant's id carries. */ +const ONE_MILLION_MARKER = /\[1m\]$/i; + +/** + * The models in an endpoint's answer: the OpenAI-shaped `data` array, a + * `models` array or a bare array, of objects with an `id` or of plain id + * strings. Duplicates, blanks and anything that is not a model are + * dropped; an answer that is not a list yields nothing. + */ +export function parseProviderModels(body: unknown): EndpointModel[] { + const list = Array.isArray(body) + ? body + : typeof body === 'object' && body !== null + ? ((body as { data?: unknown }).data ?? (body as { models?: unknown }).models) + : undefined; + if (!Array.isArray(list)) return []; + const models: EndpointModel[] = []; + const seen = new Set(); + for (const raw of list) { + const entry = typeof raw === 'object' && raw !== null ? (raw as Record) : undefined; + const listed = typeof raw === 'string' ? raw : entry?.id; + if (typeof listed !== 'string' || listed.trim() === '') continue; + const marked = ONE_MILLION_MARKER.test(listed); + const bare = listed.replace(ONE_MILLION_MARKER, ''); + const hex = CCR_ENCODED_ID.exec(bare)?.[1]; + const id = (hex && Buffer.from(hex, 'hex').toString('utf8')) || bare; + if (seen.has(id)) { + // The same model listed with and without the 1M marker: one entry, + // which has the window. + if (marked) { + const known = models.find((m) => m.id === id); + if (known && known.oneMillionContext === undefined) known.oneMillionContext = true; + } + continue; + } + seen.add(id); + const slash = id.indexOf('/'); + const provider = slash > 0 && slash < id.length - 1 ? id.slice(0, slash) : undefined; + const label = entry ? labelOf(entry, id, provider) : undefined; + const contextWindow = entry ? contextWindowOf(entry) : undefined; + const flag = entry ? oneMillionFlag(entry) : undefined; + const oneMillionContext = flag ?? (marked ? true : undefined); + models.push({ + id, + ...(label ? { label } : {}), + ...(provider ? { provider } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(oneMillionContext !== undefined ? { oneMillionContext } : {}), + }); + if (models.length >= MAX_ENDPOINT_MODELS) break; + } + return models; +} + +/** The entry's own name (`display_name`, else `name`), without a leading + * `/`; nothing when it only repeats the id. */ +function labelOf(entry: Record, id: string, provider: string | undefined): string | undefined { + const named = [entry.display_name, entry.name].find((n): n is string => typeof n === 'string' && n.trim() !== ''); + if (!named) return undefined; + const label = provider && named.startsWith(`${provider}/`) ? named.slice(provider.length + 1) : named; + return label === id ? undefined : label; +} + +/** A model's context size, when the endpoint states one (they spell it + * several ways; 0 means "unknown" to some). */ +function contextWindowOf(entry: Record): number | undefined { + const window = (entry.capabilities as { context_window?: Record } | undefined)?.context_window; + for (const value of [ + entry.context_window, + entry.context_length, + entry.contextWindow, + entry.max_input_tokens, + window?.max_input_tokens, + ]) { + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value; + } + return undefined; +} + +function oneMillionFlag(entry: Record): boolean | undefined { + const window = (entry.capabilities as { context_window?: Record } | undefined)?.context_window; + return typeof window?.supports_1m_context === 'boolean' ? window.supports_1m_context : undefined; +} + +export interface FetchProviderModelsOptions { + /** Sent as a Bearer token, when given. */ + token?: string; + /** Any further request headers. */ + headers?: Record; + httpGet: HttpGet; + /** Where a failure is reported; `tag` prefixes each line. */ + log: (message: string) => void; + tag: string; +} + +/** + * The models the endpoint at `baseUrl` serves, or `undefined` when they + * could not be read: unreachable, refused, an answer that is not a model + * list, or an empty one. Each failure is logged; none throws. + */ +export async function fetchProviderModels( + baseUrl: string, + options: FetchProviderModelsOptions, +): Promise { + const { log, tag } = options; + let url: string; + try { + url = providerModelsUrl(baseUrl); + } catch { + log(`${tag} '${baseUrl}' is not a URL; no model list`); + return undefined; + } + let body: unknown; + try { + const response = await options.httpGet(url, { + ...options.headers, + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), + }); + if (response.status < 200 || response.status >= 300) { + log(`${tag} ${baseUrl} answered ${response.status} for its model list`); + return undefined; + } + body = JSON.parse(response.text ?? '') as unknown; + } catch (error) { + log(`${tag} could not read the model list from ${url}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + const models = parseProviderModels(body); + if (models.length === 0) { + log(`${tag} ${baseUrl} listed no models`); + return undefined; + } + return models; +} From cfb7e6cbae1ebb97b78f03587168eb7f48a14b19 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:44:07 +0200 Subject: [PATCH 2/6] feat: a provider profile can take its models from the provider A provider profile had to list its models by hand, copied from the provider's own /v1/models. Now the phone can ask the bridge to read them instead: with modelsFromProvider set, every save reads the endpoint's list with the profile's token and stores what it serves (at most 200), or refuses the save when there is no list to store. A default model the provider no longer lists is dropped, and a newer save of the same profile wins over a list still on its way. The token goes only to the URL the profile names: the request follows no redirect (neither does the token check any more), the answer is read up to a size cap, and ids and labels that are not model names are dropped. The flag is stored and reported back, so a phone shows where a profile's models come from and can read them again. On the phone the editor reads the models from the provider by default (OpenRouter's preset too), and a profile card offers Refresh models. A model list now always shows who offers each model, also when one provider offers them all. Co-Authored-By: Claude Code --- .../plus/ui/components/SelectField.kt | 6 +- .../plus/ui/screens/MachineProviders.kt | 93 ++++++++-- .../plus/ui/screens/NewSessionScreen.kt | 6 +- .../main/java/uniffi/client_ffi/client_ffi.kt | 17 ++ crates/bridge-core/src/engine/mod.rs | 5 + crates/bridge-core/src/engine/settings.rs | 66 ++++++- crates/bridge-core/src/io.rs | 9 +- crates/bridge-core/src/settings.rs | 10 +- crates/bridge-core/tests/settings.rs | 103 ++++++++++- crates/bridge-runtime/src/runtime.rs | 10 +- crates/bridge-runtime/src/work.rs | 173 +++++++++++++++++- crates/client-core/src/stores/machines.rs | 1 + crates/client-ffi/src/intent.rs | 6 + crates/client-ffi/src/views.rs | 3 + crates/protocol/fixtures/corpus.json | 3 +- crates/protocol/src/codec.rs | 2 + crates/protocol/src/commands.rs | 7 + crates/protocol/src/common.rs | 5 +- docs/PROTOCOL.md | 14 +- 19 files changed, 499 insertions(+), 40 deletions(-) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt index 71aa6058..f858b549 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt @@ -97,8 +97,10 @@ fun SelectField( val current = options.firstOrNull { it.value == selected } val currentLabel = current?.label ?: if (selected.isEmpty()) placeholder else selected - // Groups are named only when there is more than one to tell apart. - val grouped = options.mapNotNull { it.group }.distinct().size > 1 + // A list that says who offers its entries always shows it, even when + // one provider offers them all: which provider a model runs on is + // worth seeing whether or not there is another to choose. + val grouped = options.any { it.group != null } Box { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt index 39a50d7a..fc689041 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt @@ -58,12 +58,19 @@ private fun profileIdFromLabel(label: String, taken: Set): String { return "$base-$n" } -/** Add-form prefills for the two providers the feature was built around. */ -private data class Preset(val name: String, val baseUrl: String, val models: List, val defaultModel: String) +/** Add-form prefills for the two providers the feature was built around. + * [fromProvider]: the provider lists its own models, so none are typed. */ +private data class Preset( + val name: String, + val baseUrl: String, + val models: List, + val defaultModel: String, + val fromProvider: Boolean, +) private val PRESETS = listOf( - Preset("Kimi K3", "https://api.moonshot.ai/anthropic", listOf(ModelRow("kimi-k3", "Kimi K3")), "kimi-k3"), - Preset("OpenRouter", "https://openrouter.ai/api", listOf(EMPTY_ROW), ""), + Preset("Kimi K3", "https://api.moonshot.ai/anthropic", listOf(ModelRow("kimi-k3", "Kimi K3")), "kimi-k3", fromProvider = false), + Preset("OpenRouter", "https://openrouter.ai/api", listOf(EMPTY_ROW), "", fromProvider = true), ) /** @@ -91,6 +98,8 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil var token by remember(machine.pubkeyHex) { mutableStateOf("") } var clearToken by remember(machine.pubkeyHex) { mutableStateOf(false) } var models by remember(machine.pubkeyHex) { mutableStateOf(listOf(EMPTY_ROW)) } + /** The bridge reads the models from the provider instead of [models]. */ + var fromProvider by remember(machine.pubkeyHex) { mutableStateOf(true) } var defaultModel by remember(machine.pubkeyHex) { mutableStateOf("") } /** Which profile's Delete awaits its confirm step. */ var confirmDelete by remember(machine.pubkeyHex) { mutableStateOf(null) } @@ -108,7 +117,18 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil // Only once there is something to check: an empty field is not an error yet. val baseUrlValid = trimmedBaseUrl.isNotEmpty() && isValidProviderBaseUrl(trimmedBaseUrl) val baseUrlError = trimmedBaseUrl.isNotEmpty() && !baseUrlValid - val canSave = label.trim().isNotEmpty() && baseUrlValid && validModels.isNotEmpty() + // Reading the provider's list takes its token: a new one, or the stored one. + val hasTokenToUse = token.trim().isNotEmpty() || (editingProfile?.hasToken == true && !clearToken) + val canSave = label.trim().isNotEmpty() && baseUrlValid && + (if (fromProvider) hasTokenToUse else validModels.isNotEmpty()) + // What the default-model picker offers: the typed list, or what the + // provider listed at the profile's last save. + val defaultChoices: List> = + if (fromProvider) { + editingProfile?.takeIf { it.modelsFromProvider }?.models.orEmpty().map { it.id to (it.label ?: it.id) } + } else { + validModels.map { m -> m.id.trim() to m.label.trim().ifEmpty { m.id.trim() } } + } fun resetForm() { editingId = null @@ -117,6 +137,7 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil token = "" clearToken = false models = listOf(EMPTY_ROW) + fromProvider = true defaultModel = "" } @@ -143,7 +164,8 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil baseUrl = p.baseUrl token = "" clearToken = false - models = p.models.map { ModelRow(it.id, it.label ?: "") } + models = if (p.modelsFromProvider) listOf(EMPTY_ROW) else p.models.map { ModelRow(it.id, it.label ?: "") } + fromProvider = p.modelsFromProvider defaultModel = p.defaultModel ?: "" formOpen = true } @@ -153,6 +175,7 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil baseUrl = preset.baseUrl models = preset.models defaultModel = preset.defaultModel + fromProvider = preset.fromProvider } fun save() { @@ -165,7 +188,13 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil token.trim().isNotEmpty() -> UniffiTristate.Set(token.trim()) else -> UniffiTristate.Keep } - val resolvedDefault = if (defaultModel.isNotEmpty() && wireModels.any { it.id == defaultModel }) defaultModel else null + // A provider-listed default the provider no longer lists is dropped + // by the bridge, which reads the list anew. + val resolvedDefault = when { + defaultModel.isEmpty() -> null + fromProvider || wireModels.any { it.id == defaultModel } -> defaultModel + else -> null + } saving = true dispatch( UniffiIntent.SetProviderProfile( @@ -175,7 +204,8 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil label = label.trim(), baseUrl = baseUrl.trim(), authToken = authToken, - models = wireModels, + models = if (fromProvider) emptyList() else wireModels, + modelsFromProvider = fromProvider, defaultModel = resolvedDefault, ), ), @@ -188,6 +218,25 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil awaitingSave = true } + /** Read a provider's model list again, changing nothing else. */ + fun refreshModels(p: UniffiProviderProfileInfo) { + saving = true + dispatch( + UniffiIntent.SetProviderProfile( + machine = machine.pubkeyHex, + profileId = p.id, + profile = UniffiProviderProfileWrite( + label = p.label, + baseUrl = p.baseUrl, + authToken = UniffiTristate.Keep, + models = emptyList(), + modelsFromProvider = true, + defaultModel = p.defaultModel, + ), + ), + ) + } + fun deleteProfile(profileId: String) { confirmDelete = null saving = true @@ -200,8 +249,9 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space3)) { Text( - "Anthropic-compatible providers (Kimi, OpenRouter, …) kept on the bridge. A new session can " + - "run on one. Their tokens stay on the bridge; this phone never stores them.", + "Providers and gateways (OpenRouter, Kimi, your own router, …) kept on the bridge. A new " + + "session can run on one, when its agent speaks the provider's API. Their tokens stay on " + + "the bridge; this phone never stores them.", color = Tokens.TextMuted, fontSize = Tokens.TextSm, ) @@ -226,6 +276,7 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil Text(p.baseUrl, color = Tokens.TextMuted, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono) Text( "${p.models.size} ${if (p.models.size == 1) "model" else "models"}" + + (if (p.modelsFromProvider) " from the provider" else "") + (p.defaultModel?.let { ", default $it" } ?: ""), color = Tokens.TextMuted, fontSize = Tokens.TextSm, @@ -244,6 +295,9 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil } else { Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { QuietButton("Edit", onClick = { openEdit(p) }) + if (p.modelsFromProvider && p.hasToken) { + QuietButton("Refresh models", onClick = { refreshModels(p) }, enabled = !saving) + } QuietButton("Delete", onClick = { confirmDelete = p.id }, danger = true) } } @@ -288,7 +342,17 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil Text("Delete the stored token when saving", color = Tokens.TextMuted, fontSize = Tokens.TextSm) } } - models.forEachIndexed { i, row -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Checkbox(checked = fromProvider, onCheckedChange = { fromProvider = it }) + Text("Read the models from the provider", color = Tokens.Text, fontSize = Tokens.TextSm) + } + if (fromProvider) { + Text( + "The bridge asks the provider for its model list each time you save, with the token above.", + color = Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) + } else models.forEachIndexed { i, row -> Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -317,17 +381,14 @@ fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfil ) } } - QuietButton("Add model", onClick = { models = models + EMPTY_ROW }) + if (!fromProvider) QuietButton("Add model", onClick = { models = models + EMPTY_ROW }) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { Text("Default model", color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) SelectField( options = buildList { add(PickerOption("", "First model")) - validModels.forEach { m -> - val id = m.id.trim() - add(PickerOption(id, m.label.trim().ifEmpty { id })) - } + defaultChoices.forEach { (id, name) -> add(PickerOption(id, name)) } }, selected = defaultModel, onSelect = { defaultModel = it }, diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt index 03aa8276..6b7d4a7a 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt @@ -199,14 +199,12 @@ internal fun NewSessionBody( val name = choices.firstOrNull { it.first == id }?.second ?: id return "Default $kind ($name)" } - // The same model can come from more than one provider; then the - // default names its provider too. - val manyProviders = modelOptions.mapNotNull { it.provider }.distinct().size > 1 + // The default names its provider too, as every entry in the list does. val defaultModelLabel = defaultLabel( "model", // A profile with no default of its own runs its first model. if (activeProfile != null) activeProfile.defaultModel ?: activeProfile.models.firstOrNull()?.id else agentModels?.defaultModel, - modelOptions.map { m -> m.id to (m.label ?: m.id) + (m.provider?.takeIf { manyProviders }?.let { " from $it" } ?: "") }, + modelOptions.map { m -> m.id to (m.label ?: m.id) + (m.provider?.let { " from $it" } ?: "") }, ) fun create() { diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index 740cc1b5..f0e93f58 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -6781,6 +6781,11 @@ data class UniffiProviderProfileInfo ( , var `models`: List , + /** + * The models are the provider's own list. + */ + var `modelsFromProvider`: kotlin.Boolean + , var `defaultModel`: kotlin.String? , var `hasToken`: kotlin.Boolean @@ -6804,6 +6809,7 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterSequenceTypeUniffiModelEntry.read(buf), + FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), FfiConverterBoolean.read(buf), ) @@ -6814,6 +6820,7 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< FfiConverterString.allocationSize(value.`label`) + FfiConverterString.allocationSize(value.`baseUrl`) + FfiConverterSequenceTypeUniffiModelEntry.allocationSize(value.`models`) + + FfiConverterBoolean.allocationSize(value.`modelsFromProvider`) + FfiConverterOptionalString.allocationSize(value.`defaultModel`) + FfiConverterBoolean.allocationSize(value.`hasToken`) ) @@ -6823,6 +6830,7 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< FfiConverterString.write(value.`label`, buf) FfiConverterString.write(value.`baseUrl`, buf) FfiConverterSequenceTypeUniffiModelEntry.write(value.`models`, buf) + FfiConverterBoolean.write(value.`modelsFromProvider`, buf) FfiConverterOptionalString.write(value.`defaultModel`, buf) FfiConverterBoolean.write(value.`hasToken`, buf) } @@ -6842,6 +6850,12 @@ data class UniffiProviderProfileWrite ( , var `models`: List , + /** + * The bridge reads the models from the provider instead (`models` is + * then ignored). + */ + var `modelsFromProvider`: kotlin.Boolean + , var `defaultModel`: kotlin.String? ){ @@ -6863,6 +6877,7 @@ public object FfiConverterTypeUniffiProviderProfileWrite: FfiConverterRustBuffer FfiConverterString.read(buf), FfiConverterTypeUniffiTristate.read(buf), FfiConverterSequenceTypeUniffiProviderModelWrite.read(buf), + FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), ) } @@ -6872,6 +6887,7 @@ public object FfiConverterTypeUniffiProviderProfileWrite: FfiConverterRustBuffer FfiConverterString.allocationSize(value.`baseUrl`) + FfiConverterTypeUniffiTristate.allocationSize(value.`authToken`) + FfiConverterSequenceTypeUniffiProviderModelWrite.allocationSize(value.`models`) + + FfiConverterBoolean.allocationSize(value.`modelsFromProvider`) + FfiConverterOptionalString.allocationSize(value.`defaultModel`) ) @@ -6880,6 +6896,7 @@ public object FfiConverterTypeUniffiProviderProfileWrite: FfiConverterRustBuffer FfiConverterString.write(value.`baseUrl`, buf) FfiConverterTypeUniffiTristate.write(value.`authToken`, buf) FfiConverterSequenceTypeUniffiProviderModelWrite.write(value.`models`, buf) + FfiConverterBoolean.write(value.`modelsFromProvider`, buf) FfiConverterOptionalString.write(value.`defaultModel`, buf) } } diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index f4774d10..37467b82 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -148,6 +148,9 @@ pub struct Engine { credential_acks: BTreeMap, /// set-provider-profile waiting on the token check: ticket → (phone, profile id). profile_acks: BTreeMap, + /// set-provider-profile waiting on the provider's model list: ticket → + /// (phone, the profile to store once its models are in). + model_fetches: BTreeMap, next_ticket: u64, sync: SyncServer, pairing: Option, @@ -191,6 +194,7 @@ impl Engine { profiles: BTreeMap::new(), credential_acks: BTreeMap::new(), profile_acks: BTreeMap::new(), + model_fetches: BTreeMap::new(), next_ticket: 0, sync, pairing: None, @@ -226,6 +230,7 @@ impl Engine { self.publish_all(BridgeToPhone::GsdState(protocol::events::GsdStateMsg { session_id, gsd })); } Input::ProviderTokenChecked { ticket, valid } => self.on_provider_token_checked(ticket, valid), + Input::ProviderModelsFetched { ticket, models } => self.on_provider_models_fetched(ticket, models), Input::FileReady { session_id, text } => { if !self.send_input(&session_id, text) { log::warn!("[Engine] Uploaded image for {session_id} could not be delivered: no live session"); diff --git a/crates/bridge-core/src/engine/settings.rs b/crates/bridge-core/src/engine/settings.rs index 825638f6..c45cf5cd 100644 --- a/crates/bridge-core/src/engine/settings.rs +++ b/crates/bridge-core/src/engine/settings.rs @@ -6,7 +6,7 @@ use agent_protocol::{AgentInfo, BridgeMessage, ProviderBinding, Secret}; use protocol::commands::{SetCredentialsMsg, SetProviderProfileMsg}; -use protocol::common::{is_valid_provider_base_url, CredentialStatus, PROVIDER_BASE_URL_ERROR}; +use protocol::common::{is_valid_provider_base_url, CredentialStatus, ProviderModel, PROVIDER_BASE_URL_ERROR}; use protocol::events::{BridgeToPhone, CredentialsAckMsg, ProviderProfileAckMsg, ProviderProfilesMsg}; use protocol::tristate::Tristate; @@ -215,18 +215,22 @@ impl Engine { }) } - /// Create, update or delete one profile. An upsert with a token checks it + /// Create, update or delete one profile. An upsert whose models come + /// from the provider reads them first; one with a token then checks it /// against the profile's own endpoint before acking; every phone then /// gets the new (redacted) list. pub(super) fn on_set_provider_profile(&mut self, m: SetProviderProfileMsg, phone: &str) { let id = m.profile_id; log::info!("[Engine] set-provider-profile '{id}' from {}...", phone.get(..8).unwrap_or(phone)); - let mut next = self.profiles.clone(); + // The newest write of a profile is the one that stands: a model list + // still on its way for an older one is dropped. + self.model_fetches.retain(|_, (_, pending)| pending.id != id); let Some(write) = m.profile else { // Deleting is never refused, whatever the stored URL: a profile // that needs fixing must stay removable. Sessions bound to it // fail at their next start rather than fall back to another // account. + let mut next = self.profiles.clone(); let existed = next.remove(&id).is_some(); if let Err(err) = self.store_profiles(next) { return self.send_profile_ack(phone, &id, Err(err)); @@ -235,13 +239,13 @@ impl Engine { self.send_profile_ack(phone, &id, Ok(None)); return; }; - // The store never holds an insecure profile: the token check below - // sends the token to this URL. + // The store never holds an insecure profile: the token check and the + // model list send the token to this URL. if !is_valid_provider_base_url(&write.base_url) { log::info!("[Engine] Provider profile '{id}' refused: insecure base URL ({})", write.base_url); return self.send_profile_ack(phone, &id, Err(PROVIDER_BASE_URL_ERROR.into())); } - if write.models.is_empty() { + if !write.models_from_provider && write.models.is_empty() { return self.send_profile_ack(phone, &id, Err("A provider profile needs at least one model.".into())); } let auth_token = match write.auth_token { @@ -254,19 +258,65 @@ impl Engine { label: write.label, base_url: write.base_url, auth_token, - models: write.models, + models: if write.models_from_provider { Vec::new() } else { write.models }, + models_from_provider: write.models_from_provider, default_model: write.default_model, updated_at: Some(self.now_iso()), }; + if !profile.models_from_provider { + return self.save_profile(phone, profile); + } + let Some(token) = profile.auth_token.clone() else { + return self.send_profile_ack(phone, &id, Err("Reading the provider's models needs its API token.".into())); + }; + self.next_ticket += 1; + let ticket = self.next_ticket; + let base_url = profile.base_url.clone(); + self.model_fetches.insert(ticket, (phone.to_string(), profile)); + self.out.push(Effect::FetchProviderModels { ticket, base_url, token }); + } + + /// The provider's model list came back: store the profile with it, or + /// refuse the save when there is none to store. + pub(super) fn on_provider_models_fetched(&mut self, ticket: u64, models: Result, String>) { + let Some((phone, mut profile)) = self.model_fetches.remove(&ticket) else { return }; + let models = match models { + Ok(models) if models.is_empty() => Err("it lists no models".to_string()), + other => other, + }; + match models { + Ok(models) => { + // A default the provider no longer lists would start sessions + // on a model it cannot serve; its first one stands in. + if profile.default_model.as_ref().is_some_and(|d| !models.iter().any(|m| &m.id == d)) { + profile.default_model = None; + } + profile.models = models; + self.save_profile(&phone, profile); + } + Err(reason) => { + log::info!("[Engine] Provider profile '{}' refused: no model list from {} ({reason})", profile.id, profile.base_url); + let error = format!("Could not read the models from {}: {reason}", profile.base_url); + self.send_profile_ack(&phone, &profile.id, Err(error)); + } + } + } + + /// Store an upserted profile, then check its token (when it has one and + /// a model to ask for) before acking. + fn save_profile(&mut self, phone: &str, profile: ProviderProfile) { + let id = profile.id.clone(); + let mut next = self.profiles.clone(); next.insert(id.clone(), profile.clone()); if let Err(err) = self.store_profiles(next) { return self.send_profile_ack(phone, &id, Err(err)); } log::info!( - "[Engine] Provider profile saved: '{id}' (\"{}\", {}, {} model(s), hasToken={})", + "[Engine] Provider profile saved: '{id}' (\"{}\", {}, {} model(s){}, hasToken={})", profile.label, profile.base_url, profile.models.len(), + if profile.models_from_provider { " from the provider" } else { "" }, profile.auth_token.is_some() ); let model = profile.fallback_model().map(str::to_string); diff --git a/crates/bridge-core/src/io.rs b/crates/bridge-core/src/io.rs index 3151f0cf..10a248d6 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -8,7 +8,7 @@ use agent_protocol::{BridgeFrame, HostFrame, Secret}; use protocol::commands::UploadFileMsg; -use protocol::common::GsdState; +use protocol::common::{GsdState, ProviderModel}; use protocol::events::BridgeToPhone; use serde::{Deserialize, Serialize}; @@ -105,6 +105,9 @@ pub enum Input { /// Answer to [`Effect::CheckProviderToken`]; `None` when it could not be /// checked (network error). ProviderTokenChecked { ticket: u64, valid: Option }, + /// Answer to [`Effect::FetchProviderModels`]: what the provider lists, + /// or why it could not be read. + ProviderModelsFetched { ticket: u64, models: Result, String> }, /// An [`Effect::HandleFileUpload`] finished: the image is on disk and /// `text` (the user's words plus its path) is the session's next input. FileReady { session_id: String, text: String }, @@ -160,6 +163,10 @@ pub enum Effect { token: Secret, model: String, }, + /// Read the models a provider serves from its `/v1/models`, sending + /// `token`; answer with [`Input::ProviderModelsFetched`]. `base_url` has + /// passed the https rule. + FetchProviderModels { ticket: u64, base_url: String, token: Secret }, /// Fetch / reassemble an uploaded image into the workspace; answer with /// [`Input::FileReady`] once it is on disk. HandleFileUpload(UploadFileMsg), diff --git a/crates/bridge-core/src/settings.rs b/crates/bridge-core/src/settings.rs index ec04c7a4..ef8c6da5 100644 --- a/crates/bridge-core/src/settings.rs +++ b/crates/bridge-core/src/settings.rs @@ -50,8 +50,8 @@ impl StoredCredentials { } } -/// A custom provider profile: an Anthropic-compatible endpoint a session can -/// be bound to. +/// A custom provider profile: an endpoint (a provider's own API, or a +/// gateway in front of several) a session can be bound to. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderProfile { @@ -62,6 +62,9 @@ pub struct ProviderProfile { #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_token: Option, pub models: Vec, + /// `models` is the provider's own list, read again on every save. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub models_from_provider: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -81,6 +84,7 @@ impl ProviderProfile { label: self.label.clone(), base_url: self.base_url.clone(), models: self.models.clone(), + models_from_provider: self.models_from_provider, default_model: self.default_model.clone(), has_token: self.auth_token.is_some(), } @@ -120,6 +124,7 @@ mod tests { base_url: "https://x".into(), auth_token: Some(Secret::new("tok-secret")), models: vec![], + models_from_provider: false, default_model: None, updated_at: None, }; @@ -135,6 +140,7 @@ mod tests { base_url: "https://x".into(), auth_token: Some(Secret::new("t")), models: vec![ProviderModel { id: "m1".into(), label: None }], + models_from_provider: false, default_model: None, updated_at: None, }; diff --git a/crates/bridge-core/tests/settings.rs b/crates/bridge-core/tests/settings.rs index 2e1a75d0..438465f5 100644 --- a/crates/bridge-core/tests/settings.rs +++ b/crates/bridge-core/tests/settings.rs @@ -7,7 +7,7 @@ use agent_protocol::{BridgeMessage, HostMessage, SessionEvent}; use bridge_core::ports::memory::{MemoryStore, MemoryTranscripts}; use bridge_core::ports::Transcripts as _; use bridge_core::{Effect, Input, PairingCloseReason, Via}; -use protocol::common::{EntryBody, NoticeKind, OutputEntry, PROVIDER_BASE_URL_ERROR}; +use protocol::common::{EntryBody, NoticeKind, OutputEntry, ProviderModel, PROVIDER_BASE_URL_ERROR}; use protocol::crypto::generate_keypair; use protocol::events::{BridgeToPhone, PairAckReason}; use serde_json::json; @@ -266,6 +266,107 @@ fn the_token_is_kept_cleared_or_replaced_and_a_profile_can_be_deleted() { assert!(rig.messages().iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(p) if p.profiles.is_empty()))); } +/// A profile whose models come from the provider. +fn router(token: Option<&str>, default_model: Option<&str>) -> serde_json::Value { + let mut p = json!({"label":"Router","baseUrl":"https://router.test/api","models":[],"modelsFromProvider":true}); + if let Some(token) = token { + p["authToken"] = json!(token); + } + if let Some(model) = default_model { + p["defaultModel"] = json!(model); + } + p +} + +/// Take the pending model-list read: (ticket, base URL, token). +fn take_model_fetch(rig: &mut Rig) -> Option<(u64, String, String)> { + let (fetches, rest): (Vec<_>, Vec<_>) = + rig.take().into_iter().partition(|e| matches!(e, Effect::FetchProviderModels { .. })); + rig.effects = rest; + fetches.into_iter().find_map(|e| match e { + Effect::FetchProviderModels { ticket, base_url, token } => Some((ticket, base_url, token.expose().to_string())), + _ => None, + }) +} + +fn listed(ids: &[&str]) -> Vec { + ids.iter().map(|id| ProviderModel { id: (*id).into(), label: None }).collect() +} + +fn stored_profile(rig: &mut Rig) -> Option { + rig.messages().into_iter().find_map(|m| match m { + BridgeToPhone::ProviderProfiles(p) => p.profiles.into_iter().next(), + _ => None, + }) +} + +#[test] +fn a_profile_can_take_its_models_from_the_provider() { + let mut rig = Rig::new(); + rig.take(); + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(Some("tok-r"), Some("b"))})); + let (ticket, base_url, token) = take_model_fetch(&mut rig).expect("the models are read first"); + assert_eq!((base_url.as_str(), token.as_str()), ("https://router.test/api", "tok-r")); + assert!(!rig.store.snapshot().contains_key("providerProfiles"), "stored only with its models"); + rig.input(Input::ProviderModelsFetched { ticket, models: Ok(listed(&["a", "b"])) }); + // Then the token is checked as for any profile, on the default model. + let model = rig.effects.iter().find_map(|e| match e { + Effect::CheckProviderToken { model, .. } => Some(model.clone()), + _ => None, + }); + assert_eq!(model.as_deref(), Some("b")); + answer_token_check(&mut rig, Some(true)); + let info = stored_profile(&mut rig).expect("broadcast"); + assert!(info.models_from_provider); + assert_eq!(info.models.iter().map(|m| m.id.as_str()).collect::>(), ["a", "b"]); + assert_eq!(info.default_model.as_deref(), Some("b")); + assert!(rig.store.snapshot()["providerProfiles"].contains("modelsFromProvider")); +} + +#[test] +fn a_default_the_provider_no_longer_lists_is_dropped() { + let mut rig = Rig::new(); + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(Some("t"), Some("gone"))})); + let (ticket, ..) = take_model_fetch(&mut rig).unwrap(); + rig.input(Input::ProviderModelsFetched { ticket, models: Ok(listed(&["a"])) }); + answer_token_check(&mut rig, None); + assert_eq!(stored_profile(&mut rig).unwrap().default_model, None); +} + +#[test] +fn no_model_list_means_no_save() { + let mut rig = Rig::new(); + // Without a token there is nothing to read the list with. + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(None, None)})); + assert!(take_model_fetch(&mut rig).is_none()); + assert!(rig.messages().iter().any(|m| matches!(m, BridgeToPhone::ProviderProfileAck(a) if !a.success && a.error.as_deref().is_some_and(|e| e.contains("needs its API token"))))); + + for answer in [Err("the provider refused the token (HTTP 401)".to_string()), Ok(vec![])] { + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(Some("t"), None)})); + let (ticket, ..) = take_model_fetch(&mut rig).unwrap(); + rig.input(Input::ProviderModelsFetched { ticket, models: answer }); + let msgs = rig.messages(); + let error = msgs.iter().find_map(|m| match m { + BridgeToPhone::ProviderProfileAck(a) if !a.success => a.error.clone(), + _ => None, + }); + assert!(error.is_some_and(|e| e.starts_with("Could not read the models from https://router.test/api: "))); + assert!(!msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(_)))); + } + assert!(!rig.store.snapshot().contains_key("providerProfiles")); +} + +#[test] +fn a_newer_save_wins_over_a_model_list_still_on_its_way() { + let mut rig = Rig::new(); + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(Some("t"), None)})); + let (stale, ..) = take_model_fetch(&mut rig).unwrap(); + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":null})); + rig.take(); + rig.input(Input::ProviderModelsFetched { ticket: stale, models: Ok(listed(&["a"])) }); + assert!(rig.take().is_empty(), "the deleted profile is not brought back"); +} + fn with_kimi() -> Rig { let mut rig = Rig::new(); rig.host_up(); diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index 7800755c..e73d491f 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -92,6 +92,7 @@ struct Runtime { host: HostHandle, inputs: mpsc::UnboundedSender, timers: HashMap>, + /// To provider profiles' endpoints (see `work::provider_http_client`). http: reqwest::Client, nostr_http: work::NostrHttp, outcome: Outcome, @@ -164,7 +165,7 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio host, inputs, timers: HashMap::new(), - http: work::http_client(), + http: work::provider_http_client(), nostr_http, outcome: Outcome::Stopped, uploads: Rc::new(RefCell::new(Uploads::new(&first_root))), @@ -290,6 +291,13 @@ impl Runtime { let _ = inputs.send(Input::ProviderTokenChecked { ticket, valid }); }); } + Effect::FetchProviderModels { ticket, base_url, token } => { + let (inputs, http) = (self.inputs.clone(), self.http.clone()); + tokio::task::spawn_local(async move { + let models = work::fetch_provider_models(&http, &base_url, token.expose()).await; + let _ = inputs.send(Input::ProviderModelsFetched { ticket, models }); + }); + } Effect::ReadGsd { session_id, cwd } => { let (inputs, gsd) = (self.inputs.clone(), Rc::clone(&self.gsd)); tokio::task::spawn_local(async move { diff --git a/crates/bridge-runtime/src/work.rs b/crates/bridge-runtime/src/work.rs index 9e605944..58ca6842 100644 --- a/crates/bridge-runtime/src/work.rs +++ b/crates/bridge-runtime/src/work.rs @@ -4,11 +4,13 @@ //! //! HTTP to the user's Nostr servers (a Blossom server, a relay's or Blossom //! server's admin endpoint) goes through [`NostrHttp`]; the rest (provider -//! token checks) goes direct — the Tor proxy is for Nostr traffic. +//! token checks and model lists) goes direct — the Tor proxy is for Nostr +//! traffic. use std::path::Path; use std::time::Duration; +use protocol::common::ProviderModel; use tokio::process::Command; use crate::config::RegisterEndpoint; @@ -19,6 +21,17 @@ pub fn http_client() -> reqwest::Client { reqwest::Client::builder().timeout(HTTP_TIMEOUT).build().expect("the HTTP client builds") } +/// HTTP to a provider profile's endpoint, carrying its token. Redirects are +/// not followed: one could lead the token to another host, or off https — +/// only the URL the profile names (and the https rule passed) may see it. +pub fn provider_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("the HTTP client builds") +} + /// HTTP to Nostr servers. With a Tor proxy set it all goes through the /// proxy, as every relay connection does — those servers learn no more /// about the bridge than the relays do — except to loopback, which never @@ -104,6 +117,98 @@ pub async fn check_provider_token(http: &reqwest::Client, base_url: &str, token: } } +/// How many of a provider's models a profile keeps: the list is for a phone +/// screen, and every phone gets it with each profile list. +const MAX_PROVIDER_MODELS: usize = 200; +/// The most of a model list that is read. Some providers describe every +/// model at length (OpenRouter's list is a few megabytes); the rest is +/// dropped rather than buffered without bound. +const MAX_MODEL_LIST_BYTES: usize = 16 * 1024 * 1024; +/// The longest model id or label kept; anything longer is not a model name. +const MAX_MODEL_NAME_CHARS: usize = 200; + +/// The models the provider at `base_url` serves, from its `/v1/models` (or +/// `/models`, when the base URL already ends in `/v1`). The token goes in +/// both headers providers read it from: `Authorization` (OpenAI-style) and +/// `x-api-key` (Anthropic-style); either way it goes only to this URL. The +/// caller has already checked that `base_url` is https (or loopback http). +pub async fn fetch_provider_models(http: &reqwest::Client, base_url: &str, token: &str) -> Result, String> { + let url = provider_models_url(base_url); + let mut res = http + .get(&url) + .bearer_auth(token) + .header("x-api-key", token) + .header("anthropic-version", "2023-06-01") + .timeout(Duration::from_secs(20)) + .send() + .await + .map_err(|err| { + log::warn!("[Work] Model list from {base_url} failed (network): {err}"); + "the provider could not be reached".to_string() + })?; + let status = res.status().as_u16(); + log::info!("[Work] Model list from {base_url}: status {status}"); + match status { + 200..=299 => {} + 401 | 403 => return Err(format!("the provider refused the token (HTTP {status})")), + _ => return Err(format!("HTTP {status} from {url}")), + } + let mut body = Vec::new(); + while let Some(chunk) = res.chunk().await.map_err(|err| format!("the answer broke off: {err}"))? { + if body.len() + chunk.len() > MAX_MODEL_LIST_BYTES { + return Err("the model list is too large".into()); + } + body.extend_from_slice(&chunk); + } + let json: serde_json::Value = serde_json::from_slice(&body).map_err(|_| "the answer is not a model list".to_string())?; + Ok(parse_provider_models(&json)) +} + +/// Where a provider lists its models: under `/v1` of its root, unless the +/// root already ends in `/v1`. +fn provider_models_url(base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + let path = reqwest::Url::parse(base).map(|u| u.path().to_string()).unwrap_or_default(); + if path.ends_with("/v1") { + format!("{base}/models") + } else { + format!("{base}/v1/models") + } +} + +/// The models in a provider's answer: the OpenAI-shaped `data` array, a +/// `models` array or a bare array, of objects with an `id` (and maybe a +/// `display_name` or `name`) or of plain id strings. Duplicates, blanks, +/// oversized names and anything that is not a model are dropped. +fn parse_provider_models(body: &serde_json::Value) -> Vec { + let list = match body { + serde_json::Value::Array(list) => Some(list), + serde_json::Value::Object(map) => map.get("data").or_else(|| map.get("models")).and_then(|v| v.as_array()), + _ => None, + }; + let fits = |s: &str| !s.trim().is_empty() && s.chars().count() <= MAX_MODEL_NAME_CHARS && !s.chars().any(char::is_control); + let mut models: Vec = Vec::new(); + for entry in list.into_iter().flatten() { + let (id, named) = match entry { + serde_json::Value::String(id) => (id.as_str(), None), + serde_json::Value::Object(map) => match map.get("id").and_then(|v| v.as_str()) { + Some(id) => (id, map.get("display_name").or_else(|| map.get("name")).and_then(|v| v.as_str())), + None => continue, + }, + _ => continue, + }; + if !fits(id) || models.iter().any(|m| m.id == id) { + continue; + } + let label = named.filter(|n| fits(n) && *n != id).map(str::to_string); + models.push(ProviderModel { id: id.to_string(), label }); + if models.len() >= MAX_PROVIDER_MODELS { + break; + } + } + models +} + /// What a token check's HTTP status says about the token. 401/403: rejected. /// Success, or an error the provider only returns once it has accepted the /// credentials (a malformed request, a rate limit): valid. Anything else — @@ -194,6 +299,72 @@ mod tests { assert!(!is_onion_http("http://abcdef.onion.example.com/x")); } + #[test] + fn a_model_list_is_read_under_v1_of_the_root() { + assert_eq!(provider_models_url("https://openrouter.ai/api"), "https://openrouter.ai/api/v1/models"); + assert_eq!(provider_models_url("https://openrouter.ai/api/"), "https://openrouter.ai/api/v1/models"); + assert_eq!(provider_models_url("https://gw.example/v1"), "https://gw.example/v1/models"); + } + + #[test] + fn a_model_list_keeps_what_names_a_model() { + let body = serde_json::json!({"data": [ + {"id": "a/b", "name": "B"}, + {"id": "c", "display_name": "C", "name": "ignored"}, + {"id": "d", "name": "d"}, + {"id": "a/b"}, + {"id": " "}, + {"id": "bad\u{7}id"}, + {"id": "x".repeat(201)}, + {"no": "id"}, + 7, + ]}); + let ids: Vec<(String, Option)> = parse_provider_models(&body).into_iter().map(|m| (m.id, m.label)).collect(); + assert_eq!(ids, [("a/b".into(), Some("B".into())), ("c".into(), Some("C".into())), ("d".into(), None)]); + assert_eq!(parse_provider_models(&serde_json::json!(["x", "y"])).len(), 2); + assert_eq!(parse_provider_models(&serde_json::json!({"models": [{"id": "z"}]})).len(), 1); + assert!(parse_provider_models(&serde_json::json!({"error": "nope"})).is_empty()); + let many: Vec<_> = (0..250).map(|i| serde_json::json!({"id": format!("m{i}")})).collect(); + assert_eq!(parse_provider_models(&serde_json::json!({"data": many})).len(), MAX_PROVIDER_MODELS); + } + + /// A one-shot HTTP server on loopback: answers the first request with + /// `response` and hands back the request it read. + async fn serve_once(response: &'static str) -> (String, tokio::task::JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0; 4096]; + let n = socket.read(&mut buf).await.unwrap(); + socket.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8_lossy(&buf[..n]).to_string() + }); + (base, task) + } + + #[tokio::test] + async fn a_model_list_is_asked_with_the_token_and_never_follows_a_redirect() { + let http = provider_http_client(); + let (base, req) = serve_once( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 22\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m1\"}]}", + ) + .await; + let models = fetch_provider_models(&http, &base, "tok-1").await.unwrap(); + assert_eq!(models, [ProviderModel { id: "m1".into(), label: None }]); + let req = req.await.unwrap().to_lowercase(); + assert!(req.starts_with("get /v1/models "), "{req}"); + assert!(req.contains("authorization: bearer tok-1") && req.contains("x-api-key: tok-1"), "{req}"); + + let (base, _) = serve_once("HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\nconnection: close\r\n\r\n").await; + assert!(fetch_provider_models(&http, &base, "t").await.unwrap_err().contains("refused the token")); + + let (base, _) = + serve_once("HTTP/1.1 302 Found\r\nlocation: http://elsewhere.example/\r\ncontent-length: 0\r\nconnection: close\r\n\r\n").await; + assert!(fetch_provider_models(&http, &base, "t").await.unwrap_err().contains("HTTP 302")); + } + #[tokio::test] async fn git_head_is_none_outside_a_repository() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/client-core/src/stores/machines.rs b/crates/client-core/src/stores/machines.rs index f5cb0a10..a7b1ee2b 100644 --- a/crates/client-core/src/stores/machines.rs +++ b/crates/client-core/src/stores/machines.rs @@ -1850,6 +1850,7 @@ mod tests { label: "Anthropic".into(), base_url: "https://api.example".into(), models: vec![], + models_from_provider: false, default_model: None, has_token: true, }], diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 17fef077..179ae439 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -97,6 +97,9 @@ pub struct UniffiProviderProfileWrite { pub base_url: String, pub auth_token: UniffiTristate, pub models: Vec, + /// The bridge reads the models from the provider instead (`models` is + /// then ignored). + pub models_from_provider: bool, pub default_model: Option, } @@ -523,6 +526,7 @@ impl TryFrom for Intent { base_url: p.base_url, auth_token: p.auth_token.into(), models: p.models.into_iter().map(|m| ProviderModel { id: m.id, label: m.label }).collect(), + models_from_provider: p.models_from_provider, default_model: p.default_model, }), }, @@ -748,6 +752,7 @@ mod tests { base_url: "https://api.moonshot.ai/anthropic".into(), auth_token: UniffiTristate::Set { value: "sk-1".into() }, models: vec![UniffiProviderModelWrite { id: "kimi-k3".into(), label: Some("Kimi K3".into()) }], + models_from_provider: true, default_model: Some("kimi-k3".into()), }), }; @@ -762,6 +767,7 @@ mod tests { base_url: "https://api.moonshot.ai/anthropic".into(), auth_token: Tristate::Set("sk-1".into()), models: vec![ProviderModel { id: "kimi-k3".into(), label: Some("Kimi K3".into()) }], + models_from_provider: true, default_model: Some("kimi-k3".into()), }), } diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index b65a20ff..d5416869 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -342,6 +342,8 @@ pub struct UniffiProviderProfileInfo { pub label: String, pub base_url: String, pub models: Vec, + /// The models are the provider's own list. + pub models_from_provider: bool, pub default_model: Option, pub has_token: bool, } @@ -754,6 +756,7 @@ pub fn build_uniffi_machines_view(v: &MachinesView) -> UniffiMachinesView { .iter() .map(|m| UniffiModelEntry { id: m.id.clone(), label: m.label.clone(), provider: Some(p.label.clone()) }) .collect(), + models_from_provider: p.models_from_provider, default_model: p.default_model.clone(), has_token: p.has_token, }) diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 1f92e7c2..6bbef1ff 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -53,6 +53,7 @@ { "type": "set-provider-profile", "profileId": "p", "profile": null }, { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "https://api.example.com/v1", "authToken": "tok", "models": [{ "id": "m1", "label": "M1" }], "defaultModel": "m1" } }, { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "http://localhost:11434/v1", "models": [{ "id": "m1" }] } }, + { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "https://openrouter.ai/api", "authToken": "tok", "models": [], "modelsFromProvider": true, "defaultModel": "m1" } }, { "type": "provider-profiles-request" }, { "type": "session-key", "sessionKey": { "pubkeyHex": "bb", "bridgePubkeyHex": "cc", "expiresAt": 1800000000 } } ], @@ -147,7 +148,7 @@ { "type": "credentials-ack", "machine": "m", "success": false, "credentials": [], "error": "not writable" }, { "type": "pair-ack", "machine": "m", "ok": true }, { "type": "pair-ack", "machine": "m", "ok": false, "reason": "bad-token", "relays": ["wss://r"], "host": "cli" }, - { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }] }, + { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }], "modelsFromProvider": true, "hasToken": true }] }, { "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false } ], "rejected": [ diff --git a/crates/protocol/src/codec.rs b/crates/protocol/src/codec.rs index e1840939..859604de 100644 --- a/crates/protocol/src/codec.rs +++ b/crates/protocol/src/codec.rs @@ -147,6 +147,7 @@ mod tests { base_url: "http://api.example.com".into(), auth_token: Tristate::Set("tok".into()), models: vec![], + models_from_provider: false, default_model: None, }), }); @@ -163,6 +164,7 @@ mod tests { base_url: "http://localhost:11434/v1".into(), auth_token: Tristate::Keep, models: vec![], + models_from_provider: false, default_model: None, }), }); diff --git a/crates/protocol/src/commands.rs b/crates/protocol/src/commands.rs index 5bca2155..ec5f50cf 100644 --- a/crates/protocol/src/commands.rs +++ b/crates/protocol/src/commands.rs @@ -369,7 +369,14 @@ pub struct ProviderProfileWrite { pub base_url: String, #[serde(default, skip_serializing_if = "Tristate::is_keep")] pub auth_token: Tristate, + /// Ignored when `models_from_provider` is set. pub models: Vec, + /// Ask the provider for its models instead: the bridge reads its + /// `/v1/models` with the profile's token on every save, and stores + /// what it lists (or refuses the save when it lists nothing). + #[serde(default, skip_serializing_if = "super::common::is_false")] + pub models_from_provider: bool, + /// With `models_from_provider`, kept only when the provider lists it. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, } diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index f9b3f99c..0b1795c6 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -12,7 +12,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -fn is_false(b: &bool) -> bool { +pub(crate) fn is_false(b: &bool) -> bool { !*b } @@ -495,6 +495,9 @@ pub struct ProviderProfileInfo { pub label: String, pub base_url: String, pub models: Vec, + /// The models are the provider's own list, read when it was last saved. + #[serde(default, skip_serializing_if = "is_false")] + pub models_from_provider: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, pub has_token: bool, diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index c1b998d6..0718f314 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -210,14 +210,24 @@ this message, inbound; status only ever goes out.** ### Custom provider profiles -Phone-managed, bridge-stored profiles that point a session at an -Anthropic-compatible backend. Only agents with `supports.providers` accept one. +Phone-managed, bridge-stored profiles that point a session at another +endpoint: a provider's own API, or a gateway in front of several. Which wire +the endpoint must speak is the agent's (Claude Code: Anthropic's Messages +API; OpenCode: OpenAI's chat completions). Only agents with +`supports.providers` accept one. - `set-provider-profile {profileId, profile | null}`: upsert or delete (`null` deletes). `profile.authToken` is tri-state: absent = keep, `null` = clear, string = set. The base URL must be https (http only on loopback) — the bridge never stores an insecure profile, and refuses to start a session on one written before that rule. +- `profile.modelsFromProvider: true`: the phone lists no models; the bridge + reads them from the endpoint's `/v1/models` (`/models` when the base URL + already ends in `/v1`) with the profile's token, on every save, and stores + at most 200. It follows no redirect, and refuses the save when the list + cannot be read or is empty. A `defaultModel` the list does not name is + dropped. The stored profile reports the flag back, so a later save (with + the token kept) reads the list again. - `provider-profiles-request` → `provider-profiles {profiles[]}` to the asking phone; after every change the bridge broadcasts the new list to all phones. - `provider-profile-ack {profileId, success, tokenValid?, error?}`; From 4b5c7acee8cbc6db42ad15bfc81464eab8d7db05 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:51:48 +0200 Subject: [PATCH 3/6] feat: OpenCode sessions can run on a provider profile OpenCode only ran on the providers in the machine's own config, so a gateway or another provider meant editing opencode.json on the machine, models included. It now takes a provider profile like the other agents: the profile is an OpenAI-compatible endpoint, reached as a custom provider of an OpenCode server started for that profile alone. Its config arrives in the environment, on top of the operator's (MCP servers, plugins and agents still load): the profile is the only provider enabled and is the model and the small model, so nothing in the session falls back to the operator's accounts. The token sits in that server's environment only, referenced from the config, and the server answers only with a password made for it, since its API reads the config back. A server is shared by the sessions on the same profile and stops a while after the last one; a changed profile gets a new one. Session model ids are the profile's own (a slash in one is not a provider prefix), and a session switches only among them. Co-Authored-By: Claude Code --- .../__tests__/opencodeProfiles.test.ts | 201 ++++++++++++++++++ .../agent-host/src/drivers/opencode/driver.ts | 111 ++++++++-- .../src/drivers/opencode/profiles.ts | 176 +++++++++++++++ .../agent-host/src/drivers/opencode/server.ts | 7 + packages/agent-host/src/sdk/providerModels.ts | 17 +- 5 files changed, 492 insertions(+), 20 deletions(-) create mode 100644 packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts create mode 100644 packages/agent-host/src/drivers/opencode/profiles.ts diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts new file mode 100644 index 00000000..6ee7b4e8 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts @@ -0,0 +1,201 @@ +/** + * Provider-profile sessions: the server each profile runs on (its config, + * its token, its password, its lifetime) and how a session names the + * profile's models. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2/client'; +import { OpenCodeDriver } from '../driver'; +import { PROFILE_PROVIDER_ID, PROFILE_TOKEN_ENV, ProfileServers, profileConfig, type StartedServer } from '../profiles'; +import type { ProviderBinding } from '../../../sdk/types'; +import { recordingContext } from '../../../sdk/__tests__/context'; + +const router = (over: Partial = {}): ProviderBinding => ({ + id: 'router', + baseUrl: 'https://router.example/api', + authToken: 'tok-secret', + models: [{ id: 'Z.ai/glm-5', label: 'GLM 5' }, { id: 'kimi-k3' }], + defaultModel: 'kimi-k3', + ...over, +}); + +/** A fake `opencode serve`: records each start, exits when told. */ +function fakeServers(idleMs = 1_000) { + const starts: Array<{ env: Record; exit: () => void; closed: boolean }> = []; + const connects: Array<{ url: string; headers: Record }> = []; + const servers = new ProfileServers({ + start: async (env): Promise => { + let exit!: () => void; + const exited = new Promise((done) => (exit = done)); + const record = { env, exit, closed: false }; + starts.push(record); + return { + url: `http://127.0.0.1:${4100 + starts.length}`, + exited, + close: async () => { + record.closed = true; + exit(); + }, + }; + }, + log: () => {}, + idleMs, + connect: (url, headers) => { + connects.push({ url, headers }); + return { url } as unknown as OpencodeClient; + }, + }); + return { servers, starts, connects }; +} + +describe('profileConfig', () => { + it('enables only the profile, on its own models, with the token left to the environment', () => { + const config = profileConfig(router()); + expect(config).toEqual({ + enabled_providers: [PROFILE_PROVIDER_ID], + model: `${PROFILE_PROVIDER_ID}/kimi-k3`, + small_model: `${PROFILE_PROVIDER_ID}/kimi-k3`, + provider: { + [PROFILE_PROVIDER_ID]: { + npm: '@ai-sdk/openai-compatible', + name: 'router', + options: { baseURL: 'https://router.example/api/v1', apiKey: `{env:${PROFILE_TOKEN_ENV}}` }, + models: { 'Z.ai/glm-5': { name: 'GLM 5' }, 'kimi-k3': {} }, + }, + }, + }); + expect(JSON.stringify(config)).not.toContain('tok-secret'); + // A base URL that already names its version is not given another. + expect(JSON.stringify(profileConfig(router({ baseUrl: 'https://router.example/v1/' })))).toContain('"baseURL":"https://router.example/v1"'); + }); +}); + +describe('ProfileServers', () => { + it('starts one password-guarded server per profile and shares it', async () => { + const { servers, starts, connects } = fakeServers(); + const a = servers.acquire(router()); + const b = servers.acquire(router()); + await Promise.all([a.client, b.client]); + expect(starts).toHaveLength(1); + const env = starts[0]!.env; + expect(env[PROFILE_TOKEN_ENV]).toBe('tok-secret'); + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT!)).toEqual(profileConfig(router())); + const password = env.OPENCODE_SERVER_PASSWORD!; + expect(password).toMatch(/^[0-9a-f]{48}$/); + expect(connects[0]!.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); + + // Another token is another server, with another password. + await servers.acquire(router({ authToken: 'tok-2' })).client; + expect(starts).toHaveLength(2); + expect(starts[1]!.env.OPENCODE_SERVER_PASSWORD).not.toBe(password); + await servers.closeAll(); + expect(starts.every((s) => s.closed)).toBe(true); + }); + + it('keeps a server a while after its last session, then stops it', async () => { + vi.useFakeTimers(); + try { + const { servers, starts } = fakeServers(1_000); + const lease = servers.acquire(router()); + await lease.client; + lease.release(); + lease.release(); + await vi.advanceTimersByTimeAsync(500); + // Picked up again within the grace period: the same server. + const again = servers.acquire(router()); + await vi.advanceTimersByTimeAsync(2_000); + expect(starts[0]!.closed).toBe(false); + again.release(); + await vi.advanceTimersByTimeAsync(1_000); + expect(starts[0]!.closed).toBe(true); + await servers.acquire(router()).client; + expect(starts).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it('forgets a server that exited, and one that failed to start', async () => { + const { servers, starts } = fakeServers(); + await servers.acquire(router()).client; + starts[0]!.exit(); + await new Promise((r) => setTimeout(r, 0)); + await servers.acquire(router()).client; + expect(starts).toHaveLength(2); + + const failing = new ProfileServers({ + start: vi.fn().mockRejectedValueOnce(new Error('no opencode')).mockResolvedValue({ url: 'http://x', exited: new Promise(() => {}), close: async () => {} }), + log: () => {}, + connect: () => ({}) as OpencodeClient, + }); + await expect(failing.acquire(router()).client).rejects.toThrow('no opencode'); + await expect(failing.acquire(router()).client).resolves.toBeDefined(); + }); +}); + +describe('a session bound to a provider profile', () => { + function profileClient() { + async function* stream() { + await new Promise((r) => setTimeout(r, 20)); + } + return { + event: { subscribe: vi.fn().mockResolvedValue({ stream: stream() }) }, + session: { + create: vi.fn().mockResolvedValue({ data: { id: 'ses_p' } as Session, error: undefined }), + get: vi.fn(), + abort: vi.fn().mockResolvedValue({ data: true, error: undefined }), + prompt: vi.fn().mockResolvedValue({ data: {}, error: undefined }), + promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }), + }, + } as unknown as OpencodeClient; + } + + function driverWith(client: OpencodeClient) { + const started: Array> = []; + const servers = new ProfileServers({ + start: async (env) => { + started.push(env); + return { url: 'http://127.0.0.1:4199', exited: new Promise(() => {}), close: async () => {} }; + }, + log: () => {}, + connect: () => client, + }); + // The machine's own server is never asked. + const machine = { event: { subscribe: vi.fn() } } as unknown as OpencodeClient; + return { driver: OpenCodeDriver.withClient(machine, servers), started, machine }; + } + + it("runs on the profile's server, under the profile's model ids", async () => { + const client = profileClient(); + const { driver, started, machine } = driverWith(client); + expect(driver.info().supports?.providers).toBe(true); + const ctx = recordingContext(); + driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/w', provider: router() }, ctx); + await ctx.ended(); + expect(started).toHaveLength(1); + expect(machine.event.subscribe).not.toHaveBeenCalled(); + // The default model, as the profile names it. + expect(ctx.events).toContainEqual({ type: 'info', nativeSessionId: 'ses_p', model: 'kimi-k3', mode: 'ask' }); + }); + + it('is refused for a model the profile does not offer, an insecure URL or no token', () => { + const { driver } = driverWith(profileClient()); + const start = (provider: ProviderBinding, model?: string) => + driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/w', provider, ...(model ? { model } : {}) }, recordingContext()); + expect(() => start(router(), 'gpt-9')).toThrow(/does not offer the model 'gpt-9'/); + expect(() => start(router({ baseUrl: 'http://router.example' }))).toThrow(/insecure base URL/); + expect(() => start(router({ authToken: '' }))).toThrow(/no stored auth token/); + // A model id with a slash is the profile's own, not provider/model. + expect(() => start(router(), 'Z.ai/glm-5')).not.toThrow(); + }); + + it("switches only among the profile's models", async () => { + const { driver } = driverWith(profileClient()); + const ctx = recordingContext(); + const session = driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/w', provider: router() }, ctx); + await ctx.waitFor((e) => e.type === 'ready'); + await expect(session.setOption('model', 'Z.ai/glm-5')).resolves.toBeUndefined(); + await expect(session.setOption('model', 'anthropic/claude-x')).rejects.toThrow(); + await session.end(); + }); +}); diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index acfb5e26..1a5b0119 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -14,6 +14,9 @@ * fabricated number would be worse than none. Context usage is reported * only for models whose provider declares a context limit; * - no effort levels; models are per prompt (`provider/model` ids). + * + * A session bound to a provider profile runs on an OpenCode server of its + * profile's own (profiles.ts); its model ids are the profile's. */ import { createOpencodeClient } from '@opencode-ai/sdk/v2/client'; import type { @@ -37,6 +40,7 @@ import { newTranslateContext } from '../../sdk/transcript'; import type { AgentInfo, ModelEntry, + ProviderBinding, QuestionSpec, SessionOption, SlashCommand, @@ -47,7 +51,9 @@ import type { import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; +import { PROFILE_PROVIDER_ID, ProfileServers } from './profiles'; import { resolveOpenCodePath, startOpenCodeServer, type OpenCodeServerHandle } from './server'; +import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../sdk/provider'; export const OPENCODE_AGENT_ID = 'opencode'; @@ -319,16 +325,22 @@ export class OpenCodeSession implements DriverSession { /** Resolves once the OpenCode session exists server-side. */ private readonly ready: Promise<{ client: OpencodeClient; session: Session }>; + private readonly catalog: () => Promise; + private readonly toPromptModel: (id: string) => { providerID: string; modelID: string } | undefined; + private readonly onClose?: () => void; constructor( params: StartSession, private readonly ctx: SessionContext, clientPromise: Promise, - private readonly catalog: () => Promise = async () => ({ models: [] }), + options: OpenCodeSessionOptions = {}, ) { this.cwd = params.cwd; this.mode = params.mode ?? DEFAULT_MODE; - this.model = splitModelId(params.model ?? undefined); + this.catalog = options.catalog ?? (async () => ({ models: [] })); + this.toPromptModel = options.toPromptModel ?? splitModelId; + this.onClose = options.onClose; + this.model = params.model ? this.toPromptModel(params.model) : undefined; this.ready = this.init(clientPromise, params); // init() reports its own failure as `ended`; nothing else awaits this // rejection except prompt/interrupt, which catch it themselves. @@ -346,6 +358,7 @@ export class OpenCodeSession implements DriverSession { this.ended = true; this.abortController.abort(); this.ctx.emit({ type: 'ended', ...(error ? { error } : {}) }); + this.onClose?.(); } private async init( @@ -365,7 +378,7 @@ export class OpenCodeSession implements DriverSession { if (refused) throw new Error(refused); } const model = params.model ?? catalog.defaultModel; - if (!params.model) this.model = splitModelId(model); + if (!params.model && model) this.model = this.toPromptModel(model); // Subscribe BEFORE resolving/creating the session so no event in the gap // between "session exists" and "we started listening" is missed. The // stream is directory-scoped (a server can host multiple projects); this @@ -876,7 +889,7 @@ export class OpenCodeSession implements DriverSession { return; case 'model': { // Model selection is per prompt in OpenCode; the next prompt uses it. - const split = splitModelId(value); + const split = this.toPromptModel(value); if (!split) throw new Error(`'${value}' is not an OpenCode provider/model id`); const refused = unsupportedModelReason(value, (await this.catalog()).models); if (refused) throw new Error(refused); @@ -923,10 +936,40 @@ export class OpenCodeSession implements DriverSession { } catch { // Best effort — the session may never have been created, or may // already be gone server-side. + } finally { + this.onClose?.(); } } } +export interface OpenCodeSessionOptions { + /** The models a session may choose from, and its default. */ + catalog?: () => Promise; + /** How a model id the phone sends is named in a prompt; `provider/model` + * ids split at their first `/` by default. */ + toPromptModel?: (id: string) => { providerID: string; modelID: string } | undefined; + /** Called once when the session is over, however it ended. */ + onClose?: () => void; +} + +/** Why a provider profile cannot run a session, if it cannot. */ +function profileRefusal(provider: ProviderBinding, model: string | null | undefined, resume: boolean): string | undefined { + // Checked before the token: this decides whether the token may travel on + // this connection at all. The base URL is not a secret. + if (!isValidProviderBaseUrl(provider.baseUrl)) { + return ( + `provider profile '${provider.id}' has an insecure base URL (${provider.baseUrl}) — ` + + `${PROVIDER_BASE_URL_ERROR}. Its API token would travel in cleartext, so the session is refused.` + ); + } + if (!provider.authToken) return `provider profile '${provider.id}' has no stored auth token`; + if (provider.models.length === 0) return `provider profile '${provider.id}' lists no models`; + if (model && !resume && !provider.models.some((m) => m.id === model)) { + return `The provider profile '${provider.id}' does not offer the model '${model}' — choose one of its models.`; + } + return undefined; +} + export interface OpenCodeDriverOptions { /** An OpenCode server to connect to. Wins over `autoStart`. */ serverUrl?: string; @@ -941,6 +984,9 @@ export interface OpenCodeDriverOptions { lookupEnv?: NodeJS.ProcessEnv; port?: number; log: (message: string) => void; + /** The servers of provider-profile sessions; built from the options above + * when absent. */ + profileServers?: ProfileServers; } /** Why OpenCode cannot run, when it is enabled but unusable. */ @@ -958,8 +1004,28 @@ export class OpenCodeDriver implements Driver { private stopped = false; readonly plugins: PluginManager = new OpenCodePlugins(() => this.client()); readonly mcp: McpManager = new OpenCodeMcp(() => this.client()); + private readonly profiles: ProfileServers; + + private constructor(private readonly options: OpenCodeDriverOptions) { + this.profiles = + options.profileServers ?? + new ProfileServers({ + start: async (env) => { + const server = await startOpenCodeServer({ command: await this.executable(), env: { ...process.env, ...env } }); + return { url: server.url, exited: server.exited, close: () => server.close() }; + }, + log: options.log, + }); + } - private constructor(private readonly options: OpenCodeDriverOptions) {} + /** The `opencode` a profile's server runs: the one on the machine, else + * the pinned one installed on demand. */ + private async executable(): Promise { + const found = resolveOpenCodePath(this.options.binaryPath, this.options.lookupEnv); + if (found) return found; + if (this.options.installOpenCode) return this.options.installOpenCode(); + throw new Error('A provider profile runs on an OpenCode server of its own, and no `opencode` executable was found (set CODEDECK_OPENCODE_PATH).'); + } /** The server's client, starting an install that failed before over. */ private client(): Promise { @@ -999,8 +1065,8 @@ export class OpenCodeDriver implements Driver { } /** A driver over an existing client (tests). */ - static withClient(client: OpencodeClient): OpenCodeDriver { - const driver = new OpenCodeDriver({ log: () => {} }); + static withClient(client: OpencodeClient, profileServers?: ProfileServers): OpenCodeDriver { + const driver = new OpenCodeDriver({ log: () => {}, ...(profileServers ? { profileServers } : {}) }); driver.clientPromise = Promise.resolve(client); return driver; } @@ -1043,24 +1109,41 @@ export class OpenCodeDriver implements Driver { modes: OPENCODE_MODES, efforts: [], defaultMode: DEFAULT_MODE, - // No subscription usage; sessions always use the providers configured - // on the OpenCode server itself. - supports: { models: true, usage: false, providers: false, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true }, + // No subscription usage. A provider profile is reached as an + // OpenAI-compatible endpoint. + supports: { models: true, usage: false, providers: true, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true }, credentials: [], ...(this.unavailable ? { unavailableReason: this.unavailable } : {}), }; } startSession(params: StartSession, ctx: SessionContext): DriverSession { - if (!this.clientPromise && this.installs && !this.stopped) this.launchInstalled(); - if (!this.clientPromise) throw new Error(this.unavailable ?? NOT_CONFIGURED); if (params.mode !== undefined && !OPENCODE_MODES.some((m) => m.id === params.mode)) { throw new Error(`OpenCode has no mode '${params.mode}'`); } + if (params.provider) return this.startProfileSession(params, params.provider, ctx); + if (!this.clientPromise && this.installs && !this.stopped) this.launchInstalled(); + if (!this.clientPromise) throw new Error(this.unavailable ?? NOT_CONFIGURED); if (params.model && !splitModelId(params.model)) { throw new Error(`'${params.model}' is not an OpenCode provider/model id — choose one from its model list.`); } - return new OpenCodeSession(params, ctx, this.clientPromise, () => this.listModels()); + return new OpenCodeSession(params, ctx, this.clientPromise, { catalog: () => this.listModels() }); + } + + /** A session on its provider profile's own server, naming the profile's + * models as they are (no provider prefix). Refused loudly when the + * profile cannot run it — never a fallback to the machine's providers. */ + private startProfileSession(params: StartSession, provider: ProviderBinding, ctx: SessionContext): DriverSession { + const refused = profileRefusal(provider, params.model, Boolean(params.resume)); + if (refused) throw new Error(refused); + const lease = this.profiles.acquire(provider); + const models: ModelEntry[] = provider.models.map((m) => ({ id: m.id, ...(m.label ? { label: m.label } : {}) })); + const defaultModel = provider.defaultModel ?? provider.models[0]?.id; + return new OpenCodeSession(params, ctx, lease.client, { + catalog: async () => ({ models, ...(defaultModel ? { defaultModel } : {}) }), + toPromptModel: (id) => ({ providerID: PROFILE_PROVIDER_ID, modelID: id }), + onClose: () => lease.release(), + }); } /** Best-effort model list from OpenCode's configured providers, and the @@ -1101,6 +1184,6 @@ export class OpenCodeDriver implements Driver { async shutdown(): Promise { this.stopped = true; - await this.server?.close(); + await Promise.all([this.server?.close(), this.profiles.closeAll()]); } } diff --git a/packages/agent-host/src/drivers/opencode/profiles.ts b/packages/agent-host/src/drivers/opencode/profiles.ts new file mode 100644 index 00000000..73bc0d14 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/profiles.ts @@ -0,0 +1,176 @@ +/** + * OpenCode servers for sessions bound to a provider profile. + * + * A profile is an OpenAI-compatible endpoint, its token and its models; + * OpenCode reaches one as a custom provider in its config. The machine's + * own server cannot take it: its config is the operator's file, shared by + * every session, and anything written there (the token included) would stay + * on disk and reach every other session. So each profile in use gets an + * OpenCode server of its own, whose config is handed over in the + * environment when it starts — the operator's config files still load + * underneath (MCP servers, plugins, agents), with the profile on top: + * + * - the profile's endpoint is the only provider enabled, and it is the + * model and small model, so nothing in the session (titles and subagents + * included) falls back to the operator's own accounts; + * - the token is in the server's environment only, referenced from the + * config, never in a file; + * - the server answers only with a password made for it, since its API + * reads its config — the token included — back to anyone on loopback + * who asks. + * + * A server lives while sessions run on it and a little after, so a restart + * or the next session reuses it; a changed profile (a new token, other + * models) gets a new server, and the old one goes once its sessions end. + */ +import { createHash, randomBytes } from 'node:crypto'; +import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { providerApiRoot } from '../../sdk/providerModels'; +import type { ProviderBinding } from '../../sdk/types'; + +/** The provider id a profile has inside its server; a session's model ids + * are the profile's own, and its prompts name them under this. */ +export const PROFILE_PROVIDER_ID = 'codedeck'; +/** The variable the profile's token reaches its server in. */ +export const PROFILE_TOKEN_ENV = 'CODEDECK_PROVIDER_API_KEY'; +/** The user name OpenCode's server expects with its password. */ +const SERVER_USERNAME = 'opencode'; +/** How long a server with no session on it is kept for the next one. */ +export const PROFILE_SERVER_IDLE_MS = 5 * 60_000; + +/** A started server: where it listens, when it exits, how to stop it. */ +export interface StartedServer { + url: string; + exited: Promise; + close(): Promise; +} + +/** Starts `opencode serve` with `env` added to the host's environment. */ +export type StartServer = (env: Record) => Promise; + +/** A client of one profile's server, held until `release`. */ +export interface ProfileLease { + client: Promise; + /** Idempotent. */ + release(): void; +} + +/** The config a profile's server starts with, on top of the operator's. */ +export function profileConfig(provider: ProviderBinding): Record { + const fallback = provider.defaultModel ?? provider.models[0]?.id; + return { + enabled_providers: [PROFILE_PROVIDER_ID], + ...(fallback ? { model: `${PROFILE_PROVIDER_ID}/${fallback}`, small_model: `${PROFILE_PROVIDER_ID}/${fallback}` } : {}), + provider: { + [PROFILE_PROVIDER_ID]: { + npm: '@ai-sdk/openai-compatible', + name: provider.id, + options: { baseURL: providerApiRoot(provider.baseUrl), apiKey: `{env:${PROFILE_TOKEN_ENV}}` }, + models: Object.fromEntries(provider.models.map((m) => [m.id, m.label ? { name: m.label } : {}])), + }, + }, + }; +} + +interface Entry { + client: Promise; + server: Promise; + leases: number; + idle?: ReturnType; +} + +export interface ProfileServersOptions { + start: StartServer; + log: (message: string) => void; + idleMs?: number; + /** Builds the client of a started server (a seam for tests). */ + connect?: (url: string, headers: Record) => OpencodeClient; +} + +export class ProfileServers { + private readonly servers = new Map(); + private closed = false; + + constructor(private readonly options: ProfileServersOptions) {} + + /** A client of the server for `provider`, starting one when none runs. */ + acquire(provider: ProviderBinding): ProfileLease { + if (this.closed) return { client: Promise.reject(new Error('the agent host is shutting down')), release: () => {} }; + const key = fingerprint(provider); + let entry = this.servers.get(key); + if (!entry) entry = this.launch(key, provider); + clearTimeout(entry.idle); + entry.idle = undefined; + entry.leases += 1; + const held = entry; + let released = false; + return { + client: held.client, + release: () => { + if (released) return; + released = true; + held.leases -= 1; + if (held.leases === 0 && this.servers.get(key) === held) { + held.idle = setTimeout(() => this.retire(key, held), this.options.idleMs ?? PROFILE_SERVER_IDLE_MS); + held.idle.unref?.(); + } + }, + }; + } + + /** Stop every server. */ + async closeAll(): Promise { + this.closed = true; + const entries = [...this.servers.values()]; + this.servers.clear(); + await Promise.all( + entries.map(async (entry) => { + clearTimeout(entry.idle); + await entry.server.then((s) => s.close(), () => {}); + }), + ); + } + + private launch(key: string, provider: ProviderBinding): Entry { + const password = randomBytes(24).toString('hex'); + const server = this.options.start({ + OPENCODE_CONFIG_CONTENT: JSON.stringify(profileConfig(provider)), + [PROFILE_TOKEN_ENV]: provider.authToken, + OPENCODE_SERVER_USERNAME: SERVER_USERNAME, + OPENCODE_SERVER_PASSWORD: password, + }); + const headers = { authorization: `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString('base64')}` }; + const connect = this.options.connect ?? ((url, h) => createOpencodeClient({ baseUrl: url, headers: h })); + const entry: Entry = { server, client: server.then((s) => connect(s.url, headers)), leases: 0 }; + this.servers.set(key, entry); + server.then( + (s) => { + this.options.log(`[opencode] started ${s.url} for provider profile '${provider.id}'`); + // A server that died is forgotten, so the next session starts another. + void s.exited.then(() => { + if (this.servers.get(key) === entry) this.servers.delete(key); + }); + }, + (error: unknown) => { + this.options.log( + `[opencode] the server for provider profile '${provider.id}' failed to start: ${error instanceof Error ? error.message : String(error)}`, + ); + if (this.servers.get(key) === entry) this.servers.delete(key); + }, + ); + return entry; + } + + private retire(key: string, entry: Entry): void { + if (this.servers.get(key) !== entry || entry.leases > 0) return; + this.servers.delete(key); + void entry.server.then((s) => s.close(), () => {}); + } +} + +/** What makes two profiles need different servers: everything their + * config is built from. Hashed, so the token is not kept as a map key. */ +function fingerprint(provider: ProviderBinding): string { + const parts = [provider.id, provider.baseUrl, provider.authToken, provider.defaultModel ?? '', provider.models]; + return createHash('sha256').update(JSON.stringify(parts)).digest('hex'); +} diff --git a/packages/agent-host/src/drivers/opencode/server.ts b/packages/agent-host/src/drivers/opencode/server.ts index 22d1c93d..c1983942 100644 --- a/packages/agent-host/src/drivers/opencode/server.ts +++ b/packages/agent-host/src/drivers/opencode/server.ts @@ -73,6 +73,8 @@ export interface StartOpenCodeServerOptions { export interface OpenCodeServerHandle { url: string; pid: number | undefined; + /** Resolves when the process exits, however it ends. */ + exited: Promise; /** SIGTERM, wait, SIGKILL if it doesn't exit in time. Always resolves — * best-effort, matching every other shutdown-path cleanup in this repo. */ close(): Promise; @@ -128,6 +130,10 @@ export function startOpenCodeServer(opts: StartOpenCodeServerOptions): Promise((done) => { + if ((proc.exitCode ?? null) !== null || (proc.signalCode ?? null) !== null) done(); + else proc.once('exit', () => done()); + }); const timer = setTimeout(() => { if (settled) return; @@ -150,6 +156,7 @@ export function startOpenCodeServer(opts: StartOpenCodeServerOptions): Promise closeProcess(proc), }); return; diff --git a/packages/agent-host/src/sdk/providerModels.ts b/packages/agent-host/src/sdk/providerModels.ts index 0e952b9d..1b053996 100644 --- a/packages/agent-host/src/sdk/providerModels.ts +++ b/packages/agent-host/src/sdk/providerModels.ts @@ -36,14 +36,19 @@ export interface EndpointModel { export const MAX_ENDPOINT_MODELS = 200; /** - * Where an endpoint lists its models: under `/v1` of its root, unless the - * root already ends in `/v1` (both spellings are common, and every agent - * that takes a base URL accepts one or the other). + * An endpoint's versioned API root: its base URL with `/v1` appended, + * unless it already ends in `/v1` (both spellings are common, and every + * agent that takes a base URL accepts one or the other). OpenAI-style + * clients post to `/chat/completions`. */ -export function providerModelsUrl(baseUrl: string): string { +export function providerApiRoot(baseUrl: string): string { const base = baseUrl.replace(/\/+$/, ''); - const rooted = new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`; - return `${rooted}/models`; + return new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`; +} + +/** Where an endpoint lists its models: `/models`. */ +export function providerModelsUrl(baseUrl: string): string { + return `${providerApiRoot(baseUrl)}/models`; } /** claude-code-router's id for a model as it lists it to Claude Code: From c19b7c5978c86b0ee566e32a9279d607be710edd Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:27:03 +0200 Subject: [PATCH 4/6] feat: a provider profile may point at a gateway on the user's network A provider base URL had to be https, or http to this machine only, so a gateway or model server at home (http://192.168.1.2:3458) could not be a profile. Plain http is now also allowed to an IP address of the user's own network: 10/8, 172.16/12, 192.168/16, 100.64/10 (where VPN overlays such as Tailscale number their machines) and IPv6 unique local addresses. Only addresses count, never names, which DNS could point anywhere, and only their canonical spelling, since a WHATWG parser reads shorthands and leading zeros as other addresses. The token then crosses that network in cleartext, which the user chose by pointing a profile there. The rule for Nostr traffic does not change: registering a phone with a relay's admin endpoint keeps the strict https-or-loopback rule (plus onion services), now its own function. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/work.rs | 7 +- crates/protocol/src/common.rs | 88 ++++++++++++++----- docs/DEEPSEEK.md | 3 +- .../src/sdk/__tests__/provider.test.ts | 39 ++++++++ packages/agent-host/src/sdk/provider.ts | 31 +++++-- 5 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 packages/agent-host/src/sdk/__tests__/provider.test.ts diff --git a/crates/bridge-runtime/src/work.rs b/crates/bridge-runtime/src/work.rs index 58ca6842..a45387a8 100644 --- a/crates/bridge-runtime/src/work.rs +++ b/crates/bridge-runtime/src/work.rs @@ -227,9 +227,10 @@ fn token_verdict(status: u16) -> Option { /// registered). The token never goes over plaintext except to loopback or an /// onion service. pub async fn register_pubkey(http: &NostrHttp, endpoint: &RegisterEndpoint, pubkey_hex: &str) -> Result<&'static str, String> { - // The same https-or-loopback-http rule as provider base URLs, plus - // http to an onion service. - if !protocol::common::is_valid_provider_base_url(&endpoint.url) && !is_onion_http(&endpoint.url) { + // https, or http to loopback or an onion service: this is Nostr + // traffic, which never crosses a network in cleartext (provider base + // URLs may also use the LAN; this endpoint may not). + if !protocol::common::is_https_or_loopback_url(&endpoint.url) && !is_onion_http(&endpoint.url) { return Err("insecure endpoint (the admin token requires https)".into()); } if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) { diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index 0b1795c6..7e0128a8 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -432,45 +432,83 @@ pub struct RemoteSessionInfo { // --- provider base URL rule (CDX-071) --- /// The message BOTH ends show when a base URL is rejected. -pub const PROVIDER_BASE_URL_ERROR: &str = - "Base URL must be https:// (http:// is allowed only for localhost, 127.0.0.1 or [::1])"; +pub const PROVIDER_BASE_URL_ERROR: &str = "Base URL must be https:// (http:// is allowed only for this machine — localhost, \ + 127.0.0.1, [::1] — or an address on your own network, such as 192.168.1.10)"; + +/// Is `raw` https, or http to this machine (`localhost` / `127.0.0.1` / +/// `[::1]`, matched exactly)? The rule for an endpoint whose traffic must +/// not cross any network in cleartext. +pub fn is_https_or_loopback_url(raw: &str) -> bool { + match plain_http_host(raw) { + PlainHttp::NotHttp(https) => https, + PlainHttp::Host(host) => matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"), + PlainHttp::Refused => false, + } +} -/// Is `raw` an acceptable custom-provider base URL? https anywhere, or http -/// ONLY on loopback (`localhost` / `127.0.0.1` / `[::1]`, matched exactly). A -/// local model server has no cert and its traffic never leaves the machine; -/// anything else is a network hop carrying a bearer token. +/// Is `raw` an acceptable custom-provider base URL? https anywhere; http to +/// this machine, where a local model server has no cert and the traffic +/// never leaves it; or http to an IP address of the user's own network — a +/// gateway or model server at home or on the office LAN, which rarely has a +/// cert either. Its token then crosses that network in cleartext, which the +/// user chose by pointing a profile there; anything beyond it (a public +/// address, or a name DNS could point anywhere) needs https. /// -/// The http branch refuses whatever the WHATWG URL parser (what actually -/// dials, e.g. reqwest) could read differently from this minimal split: -/// userinfo (`http://evil.com@localhost`), a backslash (a path separator -/// there, so `http://evil.com\@localhost` dials evil.com), and whitespace or -/// control characters (silently removed there). Refusing is the safe side of -/// any remaining disagreement: a host this split does not recognise as -/// loopback is rejected, never guessed at. +/// Private means the IPv4 ranges set aside for local networks (10/8, +/// 172.16/12, 192.168/16), carrier-grade NAT space (100.64/10, which VPN +/// overlays such as Tailscale number their machines in), and IPv6 unique +/// local addresses (fc00::/7) — written as addresses, never as names. pub fn is_valid_provider_base_url(raw: &str) -> bool { - // Minimal scheme+host split — no url crate (protocol package stays dep-light). + if is_https_or_loopback_url(raw) { + return true; + } + match plain_http_host(raw) { + PlainHttp::Host(host) => is_private_network_address(&host), + _ => false, + } +} + +enum PlainHttp { + /// Not `http://`: whether it is a usable `https://` URL instead. + NotHttp(bool), + /// An `http://` URL's host, lower-case (an IPv6 literal without its + /// brackets). + Host(String), + /// An `http://` URL this split cannot read exactly as a WHATWG parser + /// would. + Refused, +} + +/// A minimal scheme+host split — no url crate (protocol package stays +/// dep-light). +/// +/// It refuses whatever the WHATWG URL parser (what actually dials, e.g. +/// reqwest) could read differently: userinfo (`http://evil.com@localhost`), +/// a backslash (a path separator there, so `http://evil.com\@localhost` +/// dials evil.com), and whitespace or control characters (silently removed +/// there). Refusing is the safe side of any remaining disagreement: a host +/// this split does not recognise is rejected, never guessed at. +fn plain_http_host(raw: &str) -> PlainHttp { let rest = match raw.split_once("://") { Some((scheme, rest)) => match scheme.to_ascii_lowercase().as_str() { - "https" => return !rest.is_empty(), + "https" => return PlainHttp::NotHttp(!rest.is_empty()), "http" => rest, - _ => return false, + _ => return PlainHttp::NotHttp(false), }, - None => return false, + None => return PlainHttp::NotHttp(false), }; if rest.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\\') { - return false; + return PlainHttp::Refused; } let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); if authority.contains('@') { - return false; + return PlainHttp::Refused; } let host = if let Some(stripped) = authority.strip_prefix('[') { - // IPv6 literal: [::1] or [::1]:port — nothing else after the bracket. + // IPv6 literal: [addr] or [addr]:port — nothing else after the bracket. match stripped.split_once(']') { - Some((h, after)) => { - return h == "::1" && (after.is_empty() || after.starts_with(':')) - } - None => return false, + Some((h, after)) if after.is_empty() || after.starts_with(':') => h, + _ => return PlainHttp::Refused, } } else { authority.rsplit_once(':').map_or(authority, |(h, _)| h) @@ -492,6 +530,8 @@ pub struct ProviderModel { #[serde(rename_all = "camelCase")] pub struct ProviderProfileInfo { pub id: String, + /// The agent the profile is for: the endpoint speaks that agent's API. + pub agent: String, pub label: String, pub base_url: String, pub models: Vec, diff --git a/docs/DEEPSEEK.md b/docs/DEEPSEEK.md index c27be37a..ef98b6d0 100644 --- a/docs/DEEPSEEK.md +++ b/docs/DEEPSEEK.md @@ -112,7 +112,8 @@ side — a session's provider binding applies to its whole process, so the two do not mix. Both go through the same rules as the other agents: the base URL must be -`https://` (plain `http://` only to localhost, 127.0.0.1 or `[::1]`), a token +`https://` (plain `http://` only to this machine, or to an IP address of your +own network such as a gateway at home), a token is required, and the harness's whole `DEEPSEEK_*` namespace is dropped from the environment of a bound session — an operator's native key must not be billed for a session bound somewhere else. `DSH_HOME` survives: where the diff --git a/packages/agent-host/src/sdk/__tests__/provider.test.ts b/packages/agent-host/src/sdk/__tests__/provider.test.ts new file mode 100644 index 00000000..1d6ab104 --- /dev/null +++ b/packages/agent-host/src/sdk/__tests__/provider.test.ts @@ -0,0 +1,39 @@ +/** + * The base URL rule a driver checks again before it puts a profile's token + * on a connection — the same as the bridge's (`crates/protocol`). + */ +import { describe, expect, it } from 'vitest'; +import { isValidProviderBaseUrl } from '../provider'; + +describe('isValidProviderBaseUrl', () => { + it('takes https, this machine, and an address on the user\'s own network', () => { + for (const ok of [ + 'https://openrouter.ai/api', + 'http://localhost:11434/v1', + 'http://127.0.0.1:1234', + 'http://[::1]:8080', + 'http://192.168.1.2:3458', + 'http://10.0.0.7/v1', + 'http://172.16.0.1', + 'http://100.101.102.103', + 'http://[fd12::1]:3000', + ]) { + expect(isValidProviderBaseUrl(ok), ok).toBe(true); + } + }); + + it('refuses http anywhere else, a name, and userinfo', () => { + for (const refused of [ + 'http://api.example.com', + 'http://8.8.8.8', + 'http://172.32.0.1', + 'http://[2001:db8::1]', + 'http://router.local', + 'http://user:pw@192.168.1.2', + 'ftp://192.168.1.2', + 'not a url', + ]) { + expect(isValidProviderBaseUrl(refused), refused).toBe(false); + } + }); +}); diff --git a/packages/agent-host/src/sdk/provider.ts b/packages/agent-host/src/sdk/provider.ts index d18e0e81..4d469854 100644 --- a/packages/agent-host/src/sdk/provider.ts +++ b/packages/agent-host/src/sdk/provider.ts @@ -8,11 +8,15 @@ /** The message shown when a base URL is refused (the bridge shows the same). */ export const PROVIDER_BASE_URL_ERROR = - 'Base URL must be https:// (http:// is allowed only for localhost, 127.0.0.1 or [::1])'; + 'Base URL must be https:// (http:// is allowed only for this machine — localhost, 127.0.0.1, [::1] — ' + + 'or an address on your own network, such as 192.168.1.10)'; -/** https anywhere, or http ONLY on loopback — a local model server has no - * cert and its traffic never leaves the machine; anything else is a network - * hop carrying a bearer token. */ +/** https anywhere; http to this machine, whose traffic never leaves it; or + * http to an IP address of the user's own network (a gateway at home), + * where the user chose to let the token cross that network in cleartext. + * Mirrors `crates/protocol`'s `is_valid_provider_base_url`: private means + * 10/8, 172.16/12, 192.168/16, 100.64/10 and fc00::/7, written as + * addresses — never a name, which DNS could point anywhere. */ export function isValidProviderBaseUrl(raw: string): boolean { let url: URL; try { @@ -21,6 +25,21 @@ export function isValidProviderBaseUrl(raw: string): boolean { return false; } if (url.protocol === 'https:') return url.host !== ''; - if (url.protocol !== 'http:') return false; - return ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname.toLowerCase()); + if (url.protocol !== 'http:' || url.username !== '' || url.password !== '') return false; + const host = url.hostname.toLowerCase(); + return ['localhost', '127.0.0.1', '[::1]'].includes(host) || isPrivateNetworkAddress(host); +} + +/** `host` as the URL parser normalises it: dotted IPv4, or bracketed IPv6. */ +function isPrivateNetworkAddress(host: string): boolean { + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127); + } + if (host.startsWith('[') && host.endsWith(']')) { + const first = host.slice(1, -1).split(':')[0] ?? ''; + return first !== '' && (Number.parseInt(first, 16) & 0xfe00) === 0xfc00; + } + return false; } From 3109678a819081779295c1f7f466e6ec26664067 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:27:28 +0200 Subject: [PATCH 5/6] feat: provider profiles belong to one agent, and OpenCode adds theirs An endpoint that speaks one agent's API need not speak another's (an OpenAI-compatible gateway is not a Claude Code endpoint), so a provider profile now names its agent. What a profile does is that agent's catalog entry: with supports.providers a session is bound to one, as Claude Code and DeepSeek sessions are; with the new supports.providerModels the agent adds its profiles' models to its own list. OpenCode does the latter: its server starts with every OpenCode profile as a provider of its own, beside OpenCode Zen and the operator's providers, which all stay. A profile's token sits only in that server's environment, and the server answers only with a password made for it, since its API reads its config back. The bridge hands an agent its profiles after initialize and on every change; a changed list restarts OpenCode's server, and sessions on it resume through the bridge's restart. This replaces the per-profile OpenCode servers, which hid Zen. Talking to a profile's endpoint is now the agent host's job, since only the agent knows the API it speaks: the bridge asks the profile's agent to read the endpoint's models (list-provider-models) and to check its token (check-provider), and its own Anthropic-only token check and model reader are gone. sdk/providerApi.ts holds the two APIs drivers pick from (OpenAI's and Anthropic's sign-in and one-token request); neither follows a redirect, and an answer is read up to a size cap. A profile stored before profiles named their agent keeps an empty one; no agent uses it until a save names one. HostMessage::is_reply now names every kind, so a new reply cannot be left out of it unnoticed (provider-models was, at first). Co-Authored-By: Claude Code --- crates/agent-protocol/src/codec.rs | 31 +- crates/agent-protocol/src/lib.rs | 15 +- crates/agent-protocol/src/messages.rs | 34 ++- crates/bridge-core/src/catalog.rs | 16 +- crates/bridge-core/src/engine/host.rs | 28 +- crates/bridge-core/src/engine/mod.rs | 2 - crates/bridge-core/src/engine/phone.rs | 3 + crates/bridge-core/src/engine/settings.rs | 112 +++++--- crates/bridge-core/src/io.rs | 23 +- crates/bridge-core/src/settings.rs | 24 +- crates/bridge-core/tests/settings.rs | 201 ++++++++----- crates/bridge-core/tests/support/mod.rs | 12 +- crates/bridge-runtime/src/runtime.rs | 17 -- crates/bridge-runtime/src/work.rs | 220 +-------------- crates/client-core/src/bridge_api.rs | 1 + crates/client-core/src/stores/machines.rs | 1 + crates/client-ffi/src/intent.rs | 4 + crates/client-ffi/src/views.rs | 7 + crates/protocol/fixtures/corpus.json | 10 +- crates/protocol/src/codec.rs | 2 + crates/protocol/src/commands.rs | 5 +- crates/protocol/src/common.rs | 65 ++++- crates/protocol/src/events.rs | 2 +- docs/AGENT-CANDIDATES.md | 11 + docs/PROTOCOL.md | 58 ++-- .../claude/__tests__/claudeDriver.test.ts | 8 +- .../claude/__tests__/claudeEnv.test.ts | 1 + .../agent-host/src/drivers/claude/driver.ts | 16 ++ .../agent-host/src/drivers/claude/module.ts | 3 +- .../__tests__/deepseekCatalog.test.ts | 4 +- .../deepseek/__tests__/deepseekDriver.test.ts | 8 +- .../deepseek/__tests__/deepseekEnv.test.ts | 1 + .../agent-host/src/drivers/deepseek/driver.ts | 18 +- .../agent-host/src/drivers/deepseek/module.ts | 3 +- .../__tests__/opencodeProfiles.test.ts | 201 ------------- .../__tests__/opencodeProviders.test.ts | 138 +++++++++ .../agent-host/src/drivers/opencode/driver.ts | 265 ++++++++++-------- .../agent-host/src/drivers/opencode/module.ts | 2 + .../src/drivers/opencode/profiles.ts | 176 ------------ .../src/drivers/opencode/providers.ts | 103 +++++++ packages/agent-host/src/generated/protocol.ts | 108 ++++++- packages/agent-host/src/host/host.ts | 18 ++ .../src/sdk/__tests__/providerModels.test.ts | 4 +- packages/agent-host/src/sdk/driver.ts | 15 + packages/agent-host/src/sdk/net.ts | 54 +++- packages/agent-host/src/sdk/providerApi.ts | 72 +++++ packages/agent-host/src/sdk/providerModels.ts | 57 ++-- 47 files changed, 1231 insertions(+), 948 deletions(-) delete mode 100644 packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts create mode 100644 packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts delete mode 100644 packages/agent-host/src/drivers/opencode/profiles.ts create mode 100644 packages/agent-host/src/drivers/opencode/providers.ts create mode 100644 packages/agent-host/src/sdk/providerApi.ts diff --git a/crates/agent-protocol/src/codec.rs b/crates/agent-protocol/src/codec.rs index f850fa75..cb043e4b 100644 --- a/crates/agent-protocol/src/codec.rs +++ b/crates/agent-protocol/src/codec.rs @@ -82,20 +82,25 @@ impl BridgeMessage { impl HostMessage { /// Whether this message answers a bridge request (as opposed to asking - /// or notifying). + /// or notifying). Every kind is named, so a new one cannot be left out + /// unnoticed. pub fn is_reply(&self) -> bool { - matches!( - self, + match self { Self::Initialized { .. } - | Self::Ack - | Self::Error { .. } - | Self::Models { .. } - | Self::Usage { .. } - | Self::Commands { .. } - | Self::Plugins { .. } - | Self::McpServers { .. } - | Self::SessionMcp { .. } - | Self::CredentialChecked { .. } - ) + | Self::Ack + | Self::Error { .. } + | Self::Models { .. } + | Self::Usage { .. } + | Self::Commands { .. } + | Self::Plugins { .. } + | Self::McpServers { .. } + | Self::SessionMcp { .. } + | Self::CredentialChecked { .. } + | Self::ProviderModels { .. } => true, + Self::SessionEvent { .. } + | Self::RequestPermission(_) + | Self::AskQuestion(_) + | Self::RequestPlanApproval(_) => false, + } } } diff --git a/crates/agent-protocol/src/lib.rs b/crates/agent-protocol/src/lib.rs index b76b6297..08fb8faa 100644 --- a/crates/agent-protocol/src/lib.rs +++ b/crates/agent-protocol/src/lib.rs @@ -56,7 +56,7 @@ mod tests { let start = bridge_rt(json!({"v":1,"id":"2","kind":"start-session","payload":{ "sessionId":"s1","agent":"claude-code","cwd":"/w","mode":"plan","effort":"high","model":"m", "resume":"native-1","credentials":{"anthropic_api_key":"sk"},"env":{"GITHUB_TOKEN":"gh"}, - "provider":{"id":"p","baseUrl":"https://x","authToken":"t","models":[{"id":"k"}],"defaultModel":"k"} + "provider":{"id":"p","label":"P","baseUrl":"https://x","authToken":"t","models":[{"id":"k"}],"defaultModel":"k"} }})); let BridgeMessage::StartSession(s) = start.message else { panic!("start-session") }; assert_eq!(s.credentials["anthropic_api_key"].expose(), "sk"); @@ -83,6 +83,12 @@ mod tests { bridge_rt(json!({"v":1,"id":"16","kind":"session-mcp","payload":{"sessionId":"s"}})); bridge_rt(json!({"v":1,"id":"17","kind":"session-mcp-toggle","payload":{"sessionId":"s","name":"gh","enabled":false}})); bridge_rt(json!({"v":1,"id":"10","kind":"check-credential","payload":{"agent":"claude-code","credential":"anthropic_api_key","value":"sk"}})); + bridge_rt(json!({"v":1,"id":"18","kind":"check-provider","payload":{"agent":"opencode","model":"m1", + "provider":{"id":"or","label":"OpenRouter","baseUrl":"https://openrouter.ai/api","authToken":"tok","models":[{"id":"m1"}]}}})); + bridge_rt(json!({"v":1,"id":"19","kind":"list-provider-models","payload":{"agent":"opencode","baseUrl":"https://openrouter.ai/api","authToken":"tok"}})); + bridge_rt(json!({"v":1,"id":"20","kind":"set-providers","payload":{"agent":"opencode","providers":[ + {"id":"or","label":"OpenRouter","baseUrl":"https://openrouter.ai/api","authToken":"tok","models":[{"id":"a/b","label":"B"}],"defaultModel":"a/b"}]}})); + bridge_rt(json!({"v":1,"id":"21","kind":"set-providers","payload":{"agent":"opencode","providers":[]}})); bridge_rt(json!({"v":1,"id":"h1","kind":"permission-outcome","payload":{"outcome":"selected","optionId":"allow"}})); bridge_rt(json!({"v":1,"id":"h2","kind":"plan-outcome","payload":{"outcome":"cancelled","reason":"Timed out"}})); bridge_rt(json!({"v":1,"id":"h4","kind":"plan-outcome","payload":{"outcome":"selected","optionId":"revise","feedback":"Fewer steps."}})); @@ -95,11 +101,11 @@ mod tests { host_rt(json!({"v":1,"id":"1","kind":"initialized","payload":{"hostVersion":"1","agents":[{ "id":"claude-code","displayName":"Claude Code", "modes":[{"id":"plan","label":"Plan"}],"efforts":[],"defaultMode":"default", - "supports":{"models":true,"usage":true,"providers":true,"gsd":true,"interrupt":true,"commands":true,"plugins":true,"mcp":true,"tasks":true}, + "supports":{"models":true,"usage":true,"providers":true,"providerModels":false,"gsd":true,"interrupt":true,"commands":true,"plugins":true,"mcp":true,"tasks":true}, "credentials":[{"id":"anthropic_api_key","label":"Anthropic API key","envVar":"ANTHROPIC_API_KEY"}] },{ "id":"opencode","displayName":"OpenCode","modes":[],"efforts":[], - "supports":{"models":false,"usage":false,"providers":false,"gsd":false,"interrupt":true,"commands":false,"plugins":false,"mcp":false,"tasks":false}, + "supports":{"models":false,"usage":false,"providers":false,"providerModels":true,"gsd":false,"interrupt":true,"commands":false,"plugins":false,"mcp":false,"tasks":false}, "credentials":[],"unavailableReason":"opencode is not installed" }]}})); host_rt(json!({"v":1,"id":"2","kind":"ack"})); @@ -113,6 +119,7 @@ mod tests { host_rt(json!({"v":1,"id":"6","kind":"usage","payload":{"usage":{"available":true,"windows":[{"label":"5h","utilization":12.5,"resetsAt":null}],"fetchedAt":"t"}}})); host_rt(json!({"v":1,"id":"7","kind":"credential-checked","payload":{"valid":false}})); host_rt(json!({"v":1,"id":"8","kind":"credential-checked","payload":{}})); + host_rt(json!({"v":1,"id":"9","kind":"provider-models","payload":{"models":[{"id":"a/b","label":"B"},{"id":"c"}]}})); host_rt(json!({"v":1,"kind":"session-event","payload":{"sessionId":"s","event":{"type":"ready"}}})); host_rt(json!({"v":1,"kind":"session-event","payload":{"sessionId":"s","event":{ "type":"info","nativeSessionId":"n","model":"m","mode":"plan","contextWindow":1000000,"contextPercentage":12.0}}})); @@ -206,7 +213,7 @@ mod tests { &json!({"v":1,"id":"1","kind":"start-session","payload":{ "sessionId":"s","agent":"claude-code","cwd":"/w", "credentials":{"anthropic_api_key":"sk-ant-SECRET"},"env":{"GITHUB_TOKEN":"ghp_SECRET"}, - "provider":{"id":"p","baseUrl":"https://x","authToken":"tok-SECRET","models":[]} + "provider":{"id":"p","label":"P","baseUrl":"https://x","authToken":"tok-SECRET","models":[]} }}) .to_string(), ) diff --git a/crates/agent-protocol/src/messages.rs b/crates/agent-protocol/src/messages.rs index b28403d1..e2db4d1c 100644 --- a/crates/agent-protocol/src/messages.rs +++ b/crates/agent-protocol/src/messages.rs @@ -115,11 +115,14 @@ impl From for McpServerAdd { // --- starting a session --- -/// A custom provider profile a session is bound to for its whole life. +/// A provider profile, token included: one a session is bound to for its +/// whole life, or one an agent offers the models of (`set-providers`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProviderBinding { pub id: String, + /// The name the user gave it, for showing where a model comes from. + pub label: String, pub base_url: String, pub auth_token: Secret, pub models: Vec, @@ -395,6 +398,30 @@ pub enum BridgeMessage { credential: String, value: Secret, }, + /// Check a provider profile's token with the endpoint, the way `agent` + /// would use it, on `model`. Reply: `credential-checked`. + CheckProvider { + agent: String, + provider: ProviderBinding, + model: String, + }, + /// The models the endpoint at `base_url` lists, read with `auth_token` + /// the way `agent` speaks to it (a provider profile of `agent` being + /// saved). Reply: `provider-models`, or `error` saying why there is no + /// list. + ListProviderModels { + agent: String, + base_url: String, + auth_token: Secret, + }, + /// The provider profiles of an agent whose catalog entry `supports` + /// `providerModels`, all of them: sent after `initialize` and whenever + /// one changes. The agent offers their models beside its own. Reply: + /// `ack`. + SetProviders { + agent: String, + providers: Vec, + }, /// Reply to `request-permission`. PermissionOutcome(SelectOutcome), /// Reply to `request-plan-approval`. @@ -459,11 +486,14 @@ pub enum HostMessage { #[serde(default, skip_serializing_if = "std::ops::Not::not")] project_wide: bool, }, - /// Reply to `check-credential`; absent `valid` = it could not be checked. + /// Reply to `check-credential` and `check-provider`; absent `valid` = it + /// could not be checked. CredentialChecked { #[serde(default, skip_serializing_if = "Option::is_none")] valid: Option, }, + /// Reply to `list-provider-models`: never empty. + ProviderModels { models: Vec }, /// A notification: no frame id, no reply. SessionEvent { session_id: String, diff --git a/crates/bridge-core/src/catalog.rs b/crates/bridge-core/src/catalog.rs index 4103c57f..52fce9fe 100644 --- a/crates/bridge-core/src/catalog.rs +++ b/crates/bridge-core/src/catalog.rs @@ -23,12 +23,22 @@ impl Catalog { self.agents.iter().find(|a| a.id == id) } - /// The agent, when sessions can run on it; otherwise why not. - pub fn usable(&self, id: &str) -> Result<&AgentInfo, String> { + pub fn all(&self) -> &[AgentInfo] { + &self.agents + } + + /// The agent, when this bridge has it (usable now or not); otherwise why + /// it cannot say. + pub fn known(&self, id: &str) -> Result<&AgentInfo, String> { if !self.known { return Err("The agent host is not running yet — try again in a moment.".into()); } - let agent = self.get(id).ok_or_else(|| format!("This bridge has no agent '{id}'."))?; + self.get(id).ok_or_else(|| format!("This bridge has no agent '{id}'.")) + } + + /// The agent, when sessions can run on it; otherwise why not. + pub fn usable(&self, id: &str) -> Result<&AgentInfo, String> { + let agent = self.known(id)?; match &agent.unavailable_reason { Some(reason) => Err(reason.clone()), None => Ok(agent), diff --git a/crates/bridge-core/src/engine/host.rs b/crates/bridge-core/src/engine/host.rs index 31923e39..79b57e9d 100644 --- a/crates/bridge-core/src/engine/host.rs +++ b/crates/bridge-core/src/engine/host.rs @@ -55,6 +55,11 @@ pub(crate) enum HostCall { /// A session's MCP status, asked for or answering a toggle. SessionMcp { session_id: String }, CheckCredential { ticket: u64, agent: String, credential: String, value: agent_protocol::Secret }, + /// A provider profile's token check, for the save waiting on it. + CheckProvider { ticket: u64 }, + /// A provider's model list, for the save waiting on it. + ListProviderModels { ticket: u64 }, + SetProviders { agent: String }, DeleteConversation(ConversationDelete), } @@ -192,6 +197,7 @@ impl Engine { for delete in std::mem::take(&mut self.host.deletes) { self.delete_conversation(delete); } + self.push_all_providers(); } Ok(_) => log::error!("[Engine] The agent host answered initialize with the wrong reply"), Err(err) => log::error!("[Engine] The agent host failed to initialize: {err}"), @@ -233,6 +239,26 @@ impl Engine { }; self.on_credential_checked(ticket, &agent, &credential, &value, valid); } + HostCall::CheckProvider { ticket } => { + let valid = match result { + Ok(HostMessage::CredentialChecked { valid }) => valid, + _ => None, + }; + self.on_provider_token_checked(ticket, valid); + } + HostCall::ListProviderModels { ticket } => { + let models = match result { + Ok(HostMessage::ProviderModels { models }) => Ok(models), + Ok(_) => Err("the agent host gave no model list".to_string()), + Err(err) => Err(err), + }; + self.on_provider_models_fetched(ticket, models); + } + HostCall::SetProviders { agent } => { + if let Err(err) = result { + log::warn!("[Engine] {agent} did not take its provider profiles: {err}"); + } + } HostCall::DeleteConversation(delete) => match result { Ok(_) => log::info!("[Engine] Conversation {} of {} deleted", delete.conversation_id, delete.session_id), Err(err) => log::warn!( @@ -430,7 +456,7 @@ impl Engine { // A bound profile is looked up now, at every start, so a rotated // token reaches restarts — and a deleted or insecure one refuses the // start instead of silently falling back to the agent's own account. - let provider = rec.provider_id.as_deref().map(|id| self.provider_binding(id)).transpose()?; + let provider = rec.provider_id.as_deref().map(|id| self.provider_binding(id, &rec.agent)).transpose()?; let env = self .credentials .get(None, GITHUB_PAT) diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index 37467b82..a63a6dbe 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -229,8 +229,6 @@ impl Engine { Input::Gsd { session_id, gsd } => { self.publish_all(BridgeToPhone::GsdState(protocol::events::GsdStateMsg { session_id, gsd })); } - Input::ProviderTokenChecked { ticket, valid } => self.on_provider_token_checked(ticket, valid), - Input::ProviderModelsFetched { ticket, models } => self.on_provider_models_fetched(ticket, models), Input::FileReady { session_id, text } => { if !self.send_input(&session_id, text) { log::warn!("[Engine] Uploaded image for {session_id} could not be delivered: no live session"); diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index 7f3c86e9..a059503f 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -298,6 +298,9 @@ impl Engine { } else { match profile { None => Some(format!("Unknown provider profile '{id}' — it may have been deleted on this machine.")), + Some(p) if p.agent != agent.id => { + Some(format!("Provider profile '{}' is not one of {}'s.", p.label, agent.display_name)) + } Some(p) if !is_valid_provider_base_url(&p.base_url) => Some(format!( "Provider profile '{}' has an insecure base URL ({}) — {PROVIDER_BASE_URL_ERROR}. Its API token would travel in cleartext. Edit the profile in Settings and save it again.", p.label, p.base_url diff --git a/crates/bridge-core/src/engine/settings.rs b/crates/bridge-core/src/engine/settings.rs index c45cf5cd..d9bcae1b 100644 --- a/crates/bridge-core/src/engine/settings.rs +++ b/crates/bridge-core/src/engine/settings.rs @@ -11,7 +11,7 @@ use protocol::events::{BridgeToPhone, CredentialsAckMsg, ProviderProfileAckMsg, use protocol::tristate::Tristate; use super::{Engine, HostCall}; -use crate::io::{store_keys, Effect}; +use crate::io::store_keys; use crate::settings::{ProviderProfile, StoredProfiles, GITHUB_PAT, GITHUB_PAT_LABEL}; /// A `credentials-ack` waiting on credential checks. @@ -186,26 +186,22 @@ impl Engine { // --- provider profiles --- - /// What a session bound to profile `id` starts with. Refuses a deleted - /// profile, an insecure base URL and a missing token: each would - /// otherwise start the session on the wrong account or put the token on - /// a cleartext connection. - pub(super) fn provider_binding(&self, id: &str) -> Result { + /// What a session of `agent` bound to profile `id` starts with. Refuses + /// a deleted profile, another agent's, an insecure base URL and a + /// missing token: each would otherwise start the session on the wrong + /// account or endpoint, or put the token on a cleartext connection. + pub(super) fn provider_binding(&self, id: &str, agent: &str) -> Result { let profile = self.profiles.get(id).ok_or_else(|| format!("provider profile '{id}' was deleted"))?; + if profile.agent != agent { + return Err(format!("provider profile '{id}' is no longer one of {agent}'s")); + } if !is_valid_provider_base_url(&profile.base_url) { return Err(format!( "provider profile '{id}' has an insecure base URL ({}) — {PROVIDER_BASE_URL_ERROR}. Its API token would travel in cleartext, so the session is refused.", profile.base_url )); } - let auth_token = profile.auth_token.clone().ok_or_else(|| format!("provider profile '{id}' has no stored auth token"))?; - Ok(ProviderBinding { - id: id.to_string(), - base_url: profile.base_url.clone(), - auth_token, - models: profile.models.clone(), - default_model: profile.default_model.clone(), - }) + profile.binding().ok_or_else(|| format!("provider profile '{id}' has no stored auth token")) } pub(super) fn provider_profiles_msg(&self) -> BridgeToPhone { @@ -215,9 +211,36 @@ impl Engine { }) } + /// Hand an agent whose catalog entry `supports.provider_models` its + /// profiles — every usable one, so a deleted or emptied profile goes + /// away too. + pub(super) fn push_providers(&mut self, agent: &str) { + if !self.host.initialized || !self.catalog.get(agent).is_some_and(|a| a.supports.provider_models) { + return; + } + let providers: Vec = self + .profiles + .values() + .filter(|p| p.agent == agent && is_valid_provider_base_url(&p.base_url)) + .filter_map(ProviderProfile::binding) + .collect(); + log::info!("[Engine] {agent} gets {} provider profile(s)", providers.len()); + let message = BridgeMessage::SetProviders { agent: agent.to_string(), providers }; + self.call(HostCall::SetProviders { agent: agent.to_string() }, message); + } + + /// Every agent's profiles, once the host is (back) up. + pub(super) fn push_all_providers(&mut self) { + let agents: Vec = + self.catalog.all().iter().filter(|a| a.supports.provider_models).map(|a| a.id.clone()).collect(); + for agent in agents { + self.push_providers(&agent); + } + } + /// Create, update or delete one profile. An upsert whose models come - /// from the provider reads them first; one with a token then checks it - /// against the profile's own endpoint before acking; every phone then + /// from the provider reads them first; one with a token then has it + /// checked, the way its agent uses it, before acking; every phone then /// gets the new (redacted) list. pub(super) fn on_set_provider_profile(&mut self, m: SetProviderProfileMsg, phone: &str) { let id = m.profile_id; @@ -231,12 +254,15 @@ impl Engine { // fail at their next start rather than fall back to another // account. let mut next = self.profiles.clone(); - let existed = next.remove(&id).is_some(); + let removed = next.remove(&id); if let Err(err) = self.store_profiles(next) { return self.send_profile_ack(phone, &id, Err(err)); } - log::info!("[Engine] Provider profile '{id}' deleted (existed={existed})"); + log::info!("[Engine] Provider profile '{id}' deleted (existed={})", removed.is_some()); self.send_profile_ack(phone, &id, Ok(None)); + if let Some(removed) = removed { + self.push_providers(&removed.agent); + } return; }; // The store never holds an insecure profile: the token check and the @@ -245,6 +271,14 @@ impl Engine { log::info!("[Engine] Provider profile '{id}' refused: insecure base URL ({})", write.base_url); return self.send_profile_ack(phone, &id, Err(PROVIDER_BASE_URL_ERROR.into())); } + match self.catalog.known(&write.agent) { + Ok(agent) if agent.supports.providers || agent.supports.provider_models => {} + Ok(agent) => { + let reason = format!("{} takes no provider profiles.", agent.display_name); + return self.send_profile_ack(phone, &id, Err(reason)); + } + Err(reason) => return self.send_profile_ack(phone, &id, Err(reason)), + } if !write.models_from_provider && write.models.is_empty() { return self.send_profile_ack(phone, &id, Err("A provider profile needs at least one model.".into())); } @@ -255,6 +289,7 @@ impl Engine { }; let profile = ProviderProfile { id: id.clone(), + agent: write.agent, label: write.label, base_url: write.base_url, auth_token, @@ -271,9 +306,13 @@ impl Engine { }; self.next_ticket += 1; let ticket = self.next_ticket; - let base_url = profile.base_url.clone(); + let message = + BridgeMessage::ListProviderModels { agent: profile.agent.clone(), base_url: profile.base_url.clone(), auth_token: token }; + if self.call(HostCall::ListProviderModels { ticket }, message).is_none() { + let reason = "The agent host is not running, so the provider's models cannot be read now.".to_string(); + return self.send_profile_ack(phone, &id, Err(reason)); + } self.model_fetches.insert(ticket, (phone.to_string(), profile)); - self.out.push(Effect::FetchProviderModels { ticket, base_url, token }); } /// The provider's model list came back: store the profile with it, or @@ -302,32 +341,39 @@ impl Engine { } } - /// Store an upserted profile, then check its token (when it has one and - /// a model to ask for) before acking. + /// Store an upserted profile, hand it to its agent (and take it from the + /// agent it was for before), then have its token checked before acking. fn save_profile(&mut self, phone: &str, profile: ProviderProfile) { let id = profile.id.clone(); let mut next = self.profiles.clone(); - next.insert(id.clone(), profile.clone()); + let previous = next.insert(id.clone(), profile.clone()); if let Err(err) = self.store_profiles(next) { return self.send_profile_ack(phone, &id, Err(err)); } log::info!( - "[Engine] Provider profile saved: '{id}' (\"{}\", {}, {} model(s){}, hasToken={})", + "[Engine] Provider profile saved: '{id}' for {} (\"{}\", {}, {} model(s){}, hasToken={})", + profile.agent, profile.label, profile.base_url, profile.models.len(), if profile.models_from_provider { " from the provider" } else { "" }, profile.auth_token.is_some() ); - let model = profile.fallback_model().map(str::to_string); - match (profile.auth_token, model) { - (Some(token), Some(model)) => { - self.next_ticket += 1; - let ticket = self.next_ticket; - self.profile_acks.insert(ticket, (phone.to_string(), id)); - self.out.push(Effect::CheckProviderToken { ticket, base_url: profile.base_url, token, model }); - } - _ => self.send_profile_ack(phone, &id, Ok(None)), + self.push_providers(&profile.agent); + if let Some(previous) = previous.filter(|p| p.agent != profile.agent) { + self.push_providers(&previous.agent); + } + let check = profile.binding().zip(profile.fallback_model().map(str::to_string)); + let Some((provider, model)) = check else { + return self.send_profile_ack(phone, &id, Ok(None)); + }; + self.next_ticket += 1; + let ticket = self.next_ticket; + let message = BridgeMessage::CheckProvider { agent: profile.agent.clone(), provider, model }; + if self.call(HostCall::CheckProvider { ticket }, message).is_some() { + self.profile_acks.insert(ticket, (phone.to_string(), id)); + } else { + self.send_profile_ack(phone, &id, Ok(None)); } } diff --git a/crates/bridge-core/src/io.rs b/crates/bridge-core/src/io.rs index 10a248d6..9af51e23 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -6,9 +6,9 @@ //! further inputs. Nothing here blocks, and nothing here reads a clock the //! runtime did not provide — the same inputs always give the same effects. -use agent_protocol::{BridgeFrame, HostFrame, Secret}; +use agent_protocol::{BridgeFrame, HostFrame}; use protocol::commands::UploadFileMsg; -use protocol::common::{GsdState, ProviderModel}; +use protocol::common::GsdState; use protocol::events::BridgeToPhone; use serde::{Deserialize, Serialize}; @@ -102,12 +102,6 @@ pub enum Input { GitHead { session_id: String, head: Option }, /// Answer to [`Effect::ReadGsd`]. Gsd { session_id: String, gsd: GsdState }, - /// Answer to [`Effect::CheckProviderToken`]; `None` when it could not be - /// checked (network error). - ProviderTokenChecked { ticket: u64, valid: Option }, - /// Answer to [`Effect::FetchProviderModels`]: what the provider lists, - /// or why it could not be read. - ProviderModelsFetched { ticket: u64, models: Result, String> }, /// An [`Effect::HandleFileUpload`] finished: the image is on disk and /// `text` (the user's words plus its path) is the session's next input. FileReady { session_id: String, text: String }, @@ -154,19 +148,6 @@ pub enum Effect { ReadGitHead { session_id: String, cwd: String }, /// Read the GSD workflow state of `cwd`; answer with [`Input::Gsd`]. ReadGsd { session_id: String, cwd: String }, - /// Check a provider token with a one-token request to - /// `{base_url}/v1/messages` for `model`; answer with - /// [`Input::ProviderTokenChecked`]. `base_url` has passed the https rule. - CheckProviderToken { - ticket: u64, - base_url: String, - token: Secret, - model: String, - }, - /// Read the models a provider serves from its `/v1/models`, sending - /// `token`; answer with [`Input::ProviderModelsFetched`]. `base_url` has - /// passed the https rule. - FetchProviderModels { ticket: u64, base_url: String, token: Secret }, /// Fetch / reassemble an uploaded image into the workspace; answer with /// [`Input::FileReady`] once it is on disk. HandleFileUpload(UploadFileMsg), diff --git a/crates/bridge-core/src/settings.rs b/crates/bridge-core/src/settings.rs index ef8c6da5..7fe36262 100644 --- a/crates/bridge-core/src/settings.rs +++ b/crates/bridge-core/src/settings.rs @@ -51,11 +51,17 @@ impl StoredCredentials { } /// A custom provider profile: an endpoint (a provider's own API, or a -/// gateway in front of several) a session can be bound to. +/// gateway in front of several) for one agent — a session of it can be +/// bound to the profile, or the agent offers the profile's models beside +/// its own, as its catalog entry says. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderProfile { pub id: String, + /// The agent the profile is for. Empty in a profile stored before + /// profiles had one: no agent uses it until the phone names one. + #[serde(default)] + pub agent: String, pub label: String, pub base_url: String, /// Absent = stored without a token; sessions cannot start on it. @@ -77,10 +83,24 @@ impl ProviderProfile { self.default_model.as_deref().or_else(|| self.models.first().map(|m| m.id.as_str())) } + /// What a session or an agent is handed: the profile with its token. + /// None without a token. + pub fn binding(&self) -> Option { + Some(agent_protocol::ProviderBinding { + id: self.id.clone(), + label: self.label.clone(), + base_url: self.base_url.clone(), + auth_token: self.auth_token.clone()?, + models: self.models.clone(), + default_model: self.default_model.clone(), + }) + } + /// The phone's view: whether a token is set, never the token. pub fn redacted(&self) -> ProviderProfileInfo { ProviderProfileInfo { id: self.id.clone(), + agent: self.agent.clone(), label: self.label.clone(), base_url: self.base_url.clone(), models: self.models.clone(), @@ -120,6 +140,7 @@ mod tests { creds.scope_mut(None).insert(GITHUB_PAT.into(), Secret::new("ghp_secret")); let profile = ProviderProfile { id: "p".into(), + agent: "claude-code".into(), label: "P".into(), base_url: "https://x".into(), auth_token: Some(Secret::new("tok-secret")), @@ -136,6 +157,7 @@ mod tests { fn a_profile_redacts_to_has_token() { let profile = ProviderProfile { id: "p".into(), + agent: "claude-code".into(), label: "P".into(), base_url: "https://x".into(), auth_token: Some(Secret::new("t")), diff --git a/crates/bridge-core/tests/settings.rs b/crates/bridge-core/tests/settings.rs index 438465f5..10c95ae1 100644 --- a/crates/bridge-core/tests/settings.rs +++ b/crates/bridge-core/tests/settings.rs @@ -186,7 +186,7 @@ fn set_profile(rig: &mut Rig, profile: serde_json::Value) { } fn kimi(token: Option<&str>) -> serde_json::Value { - let mut p = json!({"label":"Kimi","baseUrl":"https://api.kimi.test/anthropic","models":[{"id":"k2"}]}); + let mut p = json!({"agent":"alpha","label":"Kimi","baseUrl":"https://api.kimi.test/anthropic","models":[{"id":"k2"}]}); if let Some(token) = token { p["authToken"] = json!(token); } @@ -200,19 +200,23 @@ fn kimi_cleared() -> serde_json::Value { p } +/// A rig whose host is up with an agent of each kind: alpha binds sessions +/// to a profile, delta adds a profile's models to its own. +fn rig_with_providers() -> Rig { + let mut rig = Rig::new(); + rig.host_up_with(vec![alpha(), beta(), delta()]); + rig.take(); + rig +} + /// Answer the pending token check, if any; returns whether one was pending. fn answer_token_check(rig: &mut Rig, valid: Option) -> bool { - let (checks, rest): (Vec<_>, Vec<_>) = - rig.take().into_iter().partition(|e| matches!(e, Effect::CheckProviderToken { .. })); - rig.effects = rest; - let ticket = checks.iter().find_map(|e| match e { - Effect::CheckProviderToken { ticket, .. } => Some(*ticket), - _ => None, - }); - if let Some(ticket) = ticket { - rig.input(Input::ProviderTokenChecked { ticket, valid }); + if !rig.has_host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })) { + return false; } - ticket.is_some() + let (id, _) = rig.host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })); + rig.host_reply(&id, HostMessage::CredentialChecked { valid }); + true } fn has_token(rig: &mut Rig) -> Option { @@ -222,37 +226,60 @@ fn has_token(rig: &mut Rig) -> Option { }) } +fn ack_error(rig: &mut Rig) -> Option { + rig.messages().into_iter().find_map(|m| match m { + BridgeToPhone::ProviderProfileAck(a) if !a.success => a.error, + _ => None, + }) +} + #[test] -fn a_profile_is_stored_its_token_checked_and_the_list_broadcast_redacted() { - let mut rig = Rig::new(); - rig.take(); +fn a_profile_is_stored_its_token_checked_by_its_agent_and_the_list_broadcast_redacted() { + let mut rig = rig_with_providers(); set_profile(&mut rig, kimi(Some("tok-secret"))); - let check = rig.effects.iter().find_map(|e| match e { - Effect::CheckProviderToken { base_url, token, model, .. } => Some((base_url.clone(), token.expose().to_string(), model.clone())), - _ => None, - }); - assert_eq!(check, Some(("https://api.kimi.test/anthropic".into(), "tok-secret".into(), "k2".into()))); - answer_token_check(&mut rig, Some(false)); + let (id, msg) = rig.host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })); + match msg { + BridgeMessage::CheckProvider { agent, provider, model } => { + assert_eq!((agent.as_str(), model.as_str()), ("alpha", "k2")); + assert_eq!((provider.base_url.as_str(), provider.auth_token.expose()), ("https://api.kimi.test/anthropic", "tok-secret")); + } + _ => unreachable!(), + } + rig.host_reply(&id, HostMessage::CredentialChecked { valid: Some(false) }); let msgs = rig.messages(); assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfileAck(a) if a.success && a.token_valid == Some(false)))); - assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(p) if p.profiles[0].has_token))); + assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(p) if p.profiles[0].has_token && p.profiles[0].agent == "alpha"))); assert!(!msgs.iter().any(|m| serde_json::to_string(m).unwrap().contains("tok-secret"))); } +#[test] +fn a_profile_is_for_an_agent_that_takes_one() { + let mut rig = rig_with_providers(); + let mut p = kimi(Some("t")); + p["agent"] = json!("beta"); + set_profile(&mut rig, p); + assert_eq!(ack_error(&mut rig).as_deref(), Some("Beta takes no provider profiles.")); + let mut p = kimi(Some("t")); + p["agent"] = json!("nope"); + set_profile(&mut rig, p); + assert!(ack_error(&mut rig).is_some_and(|e| e.contains("no agent 'nope'"))); + assert!(!rig.store.snapshot().contains_key("providerProfiles")); +} + #[test] fn an_insecure_base_url_is_never_stored() { - let mut rig = Rig::new(); + let mut rig = rig_with_providers(); let mut p = kimi(Some("t")); p["baseUrl"] = json!("http://api.kimi.test"); set_profile(&mut rig, p); assert!(!answer_token_check(&mut rig, None)); - assert!(rig.messages().iter().any(|m| matches!(m, BridgeToPhone::ProviderProfileAck(a) if !a.success && a.error.as_deref() == Some(PROVIDER_BASE_URL_ERROR)))); + assert_eq!(ack_error(&mut rig).as_deref(), Some(PROVIDER_BASE_URL_ERROR)); assert!(!rig.store.snapshot().contains_key("providerProfiles")); } #[test] fn the_token_is_kept_cleared_or_replaced_and_a_profile_can_be_deleted() { - let mut rig = Rig::new(); + let mut rig = rig_with_providers(); set_profile(&mut rig, kimi(Some("t1"))); answer_token_check(&mut rig, Some(true)); assert_eq!(has_token(&mut rig), Some(true)); @@ -267,8 +294,8 @@ fn the_token_is_kept_cleared_or_replaced_and_a_profile_can_be_deleted() { } /// A profile whose models come from the provider. -fn router(token: Option<&str>, default_model: Option<&str>) -> serde_json::Value { - let mut p = json!({"label":"Router","baseUrl":"https://router.test/api","models":[],"modelsFromProvider":true}); +fn router(agent: &str, token: Option<&str>, default_model: Option<&str>) -> serde_json::Value { + let mut p = json!({"agent":agent,"label":"Router","baseUrl":"https://router.test/api","models":[],"modelsFromProvider":true}); if let Some(token) = token { p["authToken"] = json!(token); } @@ -278,19 +305,23 @@ fn router(token: Option<&str>, default_model: Option<&str>) -> serde_json::Value p } -/// Take the pending model-list read: (ticket, base URL, token). -fn take_model_fetch(rig: &mut Rig) -> Option<(u64, String, String)> { - let (fetches, rest): (Vec<_>, Vec<_>) = - rig.take().into_iter().partition(|e| matches!(e, Effect::FetchProviderModels { .. })); - rig.effects = rest; - fetches.into_iter().find_map(|e| match e { - Effect::FetchProviderModels { ticket, base_url, token } => Some((ticket, base_url, token.expose().to_string())), - _ => None, - }) +fn set_router(rig: &mut Rig, profile: serde_json::Value) { + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":profile})); +} + +/// Take the pending model-list read: (request id, base URL, token). +fn take_model_fetch(rig: &mut Rig) -> Option<(String, String, String)> { + if !rig.has_host_request(|m| matches!(m, BridgeMessage::ListProviderModels { .. })) { + return None; + } + match rig.host_request(|m| matches!(m, BridgeMessage::ListProviderModels { .. })) { + (id, BridgeMessage::ListProviderModels { base_url, auth_token, .. }) => Some((id, base_url, auth_token.expose().to_string())), + _ => unreachable!(), + } } -fn listed(ids: &[&str]) -> Vec { - ids.iter().map(|id| ProviderModel { id: (*id).into(), label: None }).collect() +fn listed(ids: &[&str]) -> HostMessage { + HostMessage::ProviderModels { models: ids.iter().map(|id| ProviderModel { id: (*id).into(), label: None }).collect() } } fn stored_profile(rig: &mut Rig) -> Option { @@ -302,20 +333,19 @@ fn stored_profile(rig: &mut Rig) -> Option Some(model.clone()), - _ => None, - }); - assert_eq!(model.as_deref(), Some("b")); - answer_token_check(&mut rig, Some(true)); + let (check, model) = match rig.host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })) { + (id, BridgeMessage::CheckProvider { model, .. }) => (id, model), + _ => unreachable!(), + }; + assert_eq!(model, "b"); + rig.host_reply(&check, HostMessage::CredentialChecked { valid: Some(true) }); let info = stored_profile(&mut rig).expect("broadcast"); assert!(info.models_from_provider); assert_eq!(info.models.iter().map(|m| m.id.as_str()).collect::>(), ["a", "b"]); @@ -325,26 +355,26 @@ fn a_profile_can_take_its_models_from_the_provider() { #[test] fn a_default_the_provider_no_longer_lists_is_dropped() { - let mut rig = Rig::new(); - rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(Some("t"), Some("gone"))})); - let (ticket, ..) = take_model_fetch(&mut rig).unwrap(); - rig.input(Input::ProviderModelsFetched { ticket, models: Ok(listed(&["a"])) }); + let mut rig = rig_with_providers(); + set_router(&mut rig, router("alpha", Some("t"), Some("gone"))); + let (id, ..) = take_model_fetch(&mut rig).unwrap(); + rig.host_reply(&id, listed(&["a"])); answer_token_check(&mut rig, None); assert_eq!(stored_profile(&mut rig).unwrap().default_model, None); } #[test] fn no_model_list_means_no_save() { - let mut rig = Rig::new(); + let mut rig = rig_with_providers(); // Without a token there is nothing to read the list with. - rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(None, None)})); + set_router(&mut rig, router("alpha", None, None)); assert!(take_model_fetch(&mut rig).is_none()); - assert!(rig.messages().iter().any(|m| matches!(m, BridgeToPhone::ProviderProfileAck(a) if !a.success && a.error.as_deref().is_some_and(|e| e.contains("needs its API token"))))); + assert!(ack_error(&mut rig).is_some_and(|e| e.contains("needs its API token"))); - for answer in [Err("the provider refused the token (HTTP 401)".to_string()), Ok(vec![])] { - rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(Some("t"), None)})); - let (ticket, ..) = take_model_fetch(&mut rig).unwrap(); - rig.input(Input::ProviderModelsFetched { ticket, models: answer }); + for answer in [HostMessage::Error { message: "the provider refused the token (HTTP 401)".into() }, listed(&[])] { + set_router(&mut rig, router("alpha", Some("t"), None)); + let (id, ..) = take_model_fetch(&mut rig).unwrap(); + rig.host_reply(&id, answer); let msgs = rig.messages(); let error = msgs.iter().find_map(|m| match m { BridgeToPhone::ProviderProfileAck(a) if !a.success => a.error.clone(), @@ -358,18 +388,53 @@ fn no_model_list_means_no_save() { #[test] fn a_newer_save_wins_over_a_model_list_still_on_its_way() { - let mut rig = Rig::new(); - rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":router(Some("t"), None)})); + let mut rig = rig_with_providers(); + set_router(&mut rig, router("alpha", Some("t"), None)); let (stale, ..) = take_model_fetch(&mut rig).unwrap(); rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":null})); rig.take(); - rig.input(Input::ProviderModelsFetched { ticket: stale, models: Ok(listed(&["a"])) }); + rig.host_reply(&stale, listed(&["a"])); assert!(rig.take().is_empty(), "the deleted profile is not brought back"); } -fn with_kimi() -> Rig { +/// The providers last handed to delta, if any were. +fn pushed_to_delta(rig: &mut Rig) -> Option> { + rig.host_frames().into_iter().rev().find_map(|f| match f.message { + BridgeMessage::SetProviders { agent, providers } if agent == "delta" => Some(providers.into_iter().map(|p| p.id).collect()), + _ => None, + }) +} + +#[test] +fn an_agent_that_adds_profile_models_gets_its_profiles_whenever_they_change() { let mut rig = Rig::new(); - rig.host_up(); + rig.host_up_with(vec![alpha(), beta(), delta()]); + assert_eq!(pushed_to_delta(&mut rig), Some(vec![]), "handed its (no) profiles at start"); + + let mut p = kimi(Some("t")); + p["agent"] = json!("delta"); + set_profile(&mut rig, p.clone()); + assert_eq!(pushed_to_delta(&mut rig), Some(vec!["kimi".to_string()])); + // Moved to another agent: delta loses it. + set_profile(&mut rig, kimi(Some("t"))); + assert_eq!(pushed_to_delta(&mut rig), Some(vec![])); + set_profile(&mut rig, p); + assert_eq!(pushed_to_delta(&mut rig), Some(vec!["kimi".to_string()])); + rig.send(json!({"type":"set-provider-profile","profileId":"kimi","profile":null})); + assert_eq!(pushed_to_delta(&mut rig), Some(vec![])); + + // A host restart hands them over again. + set_profile(&mut rig, json!({"agent":"delta","label":"K","baseUrl":"https://k.test","authToken":"t","models":[{"id":"m"}]})); + rig.take(); + rig.input(Input::HostDown { reason: "test".into() }); + rig.host_up_with(vec![alpha(), beta(), delta()]); + assert_eq!(pushed_to_delta(&mut rig), Some(vec!["kimi".to_string()])); + // An agent that binds sessions is never handed any. + assert!(!rig.has_host_request(|m| matches!(m, BridgeMessage::SetProviders { agent, .. } if agent == "alpha"))); +} + +fn with_kimi() -> Rig { + let mut rig = rig_with_providers(); set_profile(&mut rig, kimi(Some("tok"))); answer_token_check(&mut rig, Some(true)); rig.take(); @@ -408,12 +473,18 @@ fn a_provider_bound_session_starts_on_the_live_profile_with_its_model() { } #[test] -fn a_provider_session_is_refused_for_an_unknown_profile_or_an_agent_without_providers() { +fn a_provider_session_is_refused_for_an_unknown_profile_another_agents_or_an_agent_without_providers() { let mut rig = with_kimi(); rig.send(json!({"type":"create-session","agent":"alpha","providerId":"nope"})); assert!(failed_reason(&mut rig).contains("Unknown provider profile 'nope'")); rig.send(json!({"type":"create-session","agent":"beta","providerId":"kimi"})); assert!(failed_reason(&mut rig).contains("Beta does not support custom provider profiles")); + let mut p = kimi(None); + p["agent"] = json!("delta"); + set_profile(&mut rig, p); + rig.take(); + rig.send(json!({"type":"create-session","agent":"alpha","providerId":"kimi"})); + assert!(failed_reason(&mut rig).contains("is not one of Alpha's")); set_profile(&mut rig, kimi_cleared()); rig.take(); rig.send(json!({"type":"create-session","agent":"alpha","providerId":"kimi"})); diff --git a/crates/bridge-core/tests/support/mod.rs b/crates/bridge-core/tests/support/mod.rs index 368bfb4d..60797f9f 100644 --- a/crates/bridge-core/tests/support/mod.rs +++ b/crates/bridge-core/tests/support/mod.rs @@ -34,7 +34,7 @@ pub fn alpha() -> AgentInfo { efforts: vec![choice("low"), choice("high")], default_mode: Some("ask".into()), default_effort: Some("high".into()), - supports: AgentSupports { models: true, usage: true, providers: true, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true }, + supports: AgentSupports { models: true, usage: true, providers: true, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true, ..Default::default() }, credentials: vec![CredentialSpec { id: "alpha_key".into(), label: "Alpha key".into(), env_var: Some("ALPHA_KEY".into()) }], unavailable_reason: None, } @@ -65,6 +65,16 @@ pub fn gamma() -> AgentInfo { } } +/// "delta": its provider profiles add models to its own list. +pub fn delta() -> AgentInfo { + AgentInfo { + id: "delta".into(), + display_name: "Delta".into(), + supports: AgentSupports { models: true, provider_models: true, ..Default::default() }, + ..beta() + } +} + pub struct Rig { pub engine: Engine, pub system: TestSystem, diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index e73d491f..b133e95d 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -92,8 +92,6 @@ struct Runtime { host: HostHandle, inputs: mpsc::UnboundedSender, timers: HashMap>, - /// To provider profiles' endpoints (see `work::provider_http_client`). - http: reqwest::Client, nostr_http: work::NostrHttp, outcome: Outcome, uploads: Rc>, @@ -165,7 +163,6 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio host, inputs, timers: HashMap::new(), - http: work::provider_http_client(), nostr_http, outcome: Outcome::Stopped, uploads: Rc::new(RefCell::new(Uploads::new(&first_root))), @@ -284,20 +281,6 @@ impl Runtime { let _ = inputs.send(Input::GitHead { session_id, head }); }); } - Effect::CheckProviderToken { ticket, base_url, token, model } => { - let (inputs, http) = (self.inputs.clone(), self.http.clone()); - tokio::task::spawn_local(async move { - let valid = work::check_provider_token(&http, &base_url, token.expose(), &model).await; - let _ = inputs.send(Input::ProviderTokenChecked { ticket, valid }); - }); - } - Effect::FetchProviderModels { ticket, base_url, token } => { - let (inputs, http) = (self.inputs.clone(), self.http.clone()); - tokio::task::spawn_local(async move { - let models = work::fetch_provider_models(&http, &base_url, token.expose()).await; - let _ = inputs.send(Input::ProviderModelsFetched { ticket, models }); - }); - } Effect::ReadGsd { session_id, cwd } => { let (inputs, gsd) = (self.inputs.clone(), Rc::clone(&self.gsd)); tokio::task::spawn_local(async move { diff --git a/crates/bridge-runtime/src/work.rs b/crates/bridge-runtime/src/work.rs index a45387a8..78806c02 100644 --- a/crates/bridge-runtime/src/work.rs +++ b/crates/bridge-runtime/src/work.rs @@ -3,14 +3,12 @@ //! bridge down. //! //! HTTP to the user's Nostr servers (a Blossom server, a relay's or Blossom -//! server's admin endpoint) goes through [`NostrHttp`]; the rest (provider -//! token checks and model lists) goes direct — the Tor proxy is for Nostr -//! traffic. +//! server's admin endpoint) goes through [`NostrHttp`]. Provider endpoints +//! are the agent host's business, not the bridge's. use std::path::Path; use std::time::Duration; -use protocol::common::ProviderModel; use tokio::process::Command; use crate::config::RegisterEndpoint; @@ -21,17 +19,6 @@ pub fn http_client() -> reqwest::Client { reqwest::Client::builder().timeout(HTTP_TIMEOUT).build().expect("the HTTP client builds") } -/// HTTP to a provider profile's endpoint, carrying its token. Redirects are -/// not followed: one could lead the token to another host, or off https — -/// only the URL the profile names (and the https rule passed) may see it. -pub fn provider_http_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(HTTP_TIMEOUT) - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("the HTTP client builds") -} - /// HTTP to Nostr servers. With a Tor proxy set it all goes through the /// proxy, as every relay connection does — those servers learn no more /// about the bridge than the relays do — except to loopback, which never @@ -98,130 +85,6 @@ pub async fn git_head(cwd: &Path) -> Option { (out.status.success() && !head.is_empty()).then_some(head) } -/// Check a provider token with the smallest possible request to the -/// provider's own endpoint. The caller has already checked that `base_url` -/// is https (or loopback http). -pub async fn check_provider_token(http: &reqwest::Client, base_url: &str, token: &str, model: &str) -> Option { - let url = format!("{}/v1/messages", base_url.trim_end_matches('/')); - let body = serde_json::json!({"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]}); - match http.post(&url).bearer_auth(token).header("anthropic-version", "2023-06-01").json(&body).send().await { - Ok(res) => { - let status = res.status().as_u16(); - log::info!("[Work] Provider token check at {base_url}: status {status}"); - token_verdict(status) - } - Err(err) => { - log::warn!("[Work] Provider token check at {base_url} failed (network): {err}"); - None - } - } -} - -/// How many of a provider's models a profile keeps: the list is for a phone -/// screen, and every phone gets it with each profile list. -const MAX_PROVIDER_MODELS: usize = 200; -/// The most of a model list that is read. Some providers describe every -/// model at length (OpenRouter's list is a few megabytes); the rest is -/// dropped rather than buffered without bound. -const MAX_MODEL_LIST_BYTES: usize = 16 * 1024 * 1024; -/// The longest model id or label kept; anything longer is not a model name. -const MAX_MODEL_NAME_CHARS: usize = 200; - -/// The models the provider at `base_url` serves, from its `/v1/models` (or -/// `/models`, when the base URL already ends in `/v1`). The token goes in -/// both headers providers read it from: `Authorization` (OpenAI-style) and -/// `x-api-key` (Anthropic-style); either way it goes only to this URL. The -/// caller has already checked that `base_url` is https (or loopback http). -pub async fn fetch_provider_models(http: &reqwest::Client, base_url: &str, token: &str) -> Result, String> { - let url = provider_models_url(base_url); - let mut res = http - .get(&url) - .bearer_auth(token) - .header("x-api-key", token) - .header("anthropic-version", "2023-06-01") - .timeout(Duration::from_secs(20)) - .send() - .await - .map_err(|err| { - log::warn!("[Work] Model list from {base_url} failed (network): {err}"); - "the provider could not be reached".to_string() - })?; - let status = res.status().as_u16(); - log::info!("[Work] Model list from {base_url}: status {status}"); - match status { - 200..=299 => {} - 401 | 403 => return Err(format!("the provider refused the token (HTTP {status})")), - _ => return Err(format!("HTTP {status} from {url}")), - } - let mut body = Vec::new(); - while let Some(chunk) = res.chunk().await.map_err(|err| format!("the answer broke off: {err}"))? { - if body.len() + chunk.len() > MAX_MODEL_LIST_BYTES { - return Err("the model list is too large".into()); - } - body.extend_from_slice(&chunk); - } - let json: serde_json::Value = serde_json::from_slice(&body).map_err(|_| "the answer is not a model list".to_string())?; - Ok(parse_provider_models(&json)) -} - -/// Where a provider lists its models: under `/v1` of its root, unless the -/// root already ends in `/v1`. -fn provider_models_url(base_url: &str) -> String { - let base = base_url.trim_end_matches('/'); - let path = reqwest::Url::parse(base).map(|u| u.path().to_string()).unwrap_or_default(); - if path.ends_with("/v1") { - format!("{base}/models") - } else { - format!("{base}/v1/models") - } -} - -/// The models in a provider's answer: the OpenAI-shaped `data` array, a -/// `models` array or a bare array, of objects with an `id` (and maybe a -/// `display_name` or `name`) or of plain id strings. Duplicates, blanks, -/// oversized names and anything that is not a model are dropped. -fn parse_provider_models(body: &serde_json::Value) -> Vec { - let list = match body { - serde_json::Value::Array(list) => Some(list), - serde_json::Value::Object(map) => map.get("data").or_else(|| map.get("models")).and_then(|v| v.as_array()), - _ => None, - }; - let fits = |s: &str| !s.trim().is_empty() && s.chars().count() <= MAX_MODEL_NAME_CHARS && !s.chars().any(char::is_control); - let mut models: Vec = Vec::new(); - for entry in list.into_iter().flatten() { - let (id, named) = match entry { - serde_json::Value::String(id) => (id.as_str(), None), - serde_json::Value::Object(map) => match map.get("id").and_then(|v| v.as_str()) { - Some(id) => (id, map.get("display_name").or_else(|| map.get("name")).and_then(|v| v.as_str())), - None => continue, - }, - _ => continue, - }; - if !fits(id) || models.iter().any(|m| m.id == id) { - continue; - } - let label = named.filter(|n| fits(n) && *n != id).map(str::to_string); - models.push(ProviderModel { id: id.to_string(), label }); - if models.len() >= MAX_PROVIDER_MODELS { - break; - } - } - models -} - -/// What a token check's HTTP status says about the token. 401/403: rejected. -/// Success, or an error the provider only returns once it has accepted the -/// credentials (a malformed request, a rate limit): valid. Anything else — -/// 404 from a wrong base URL, a redirect, a 5xx — never reached the -/// credential check, so it proves nothing either way. -fn token_verdict(status: u16) -> Option { - match status { - 401 | 403 => Some(false), - 200..=299 | 400 | 422 | 429 => Some(true), - _ => None, - } -} - /// Register a paired phone's pubkey with an admin endpoint /// (`POST {pubkey}` with a Bearer token; 200 already registered, 201 /// registered). The token never goes over plaintext except to loopback or an @@ -255,19 +118,6 @@ pub async fn register_pubkey(http: &NostrHttp, endpoint: &RegisterEndpoint, pubk mod tests { use super::*; - #[test] - fn only_a_status_past_the_credential_check_is_a_token_verdict() { - assert_eq!(token_verdict(200), Some(true)); - assert_eq!(token_verdict(400), Some(true)); - assert_eq!(token_verdict(429), Some(true)); - assert_eq!(token_verdict(401), Some(false)); - assert_eq!(token_verdict(403), Some(false)); - assert_eq!(token_verdict(404), None, "a wrong base URL says nothing about the token"); - assert_eq!(token_verdict(301), None); - assert_eq!(token_verdict(500), None); - assert_eq!(token_verdict(503), None); - } - #[tokio::test] async fn registration_refuses_plaintext_and_bad_keys_before_any_request() { let http = NostrHttp::new(None).unwrap(); @@ -300,72 +150,6 @@ mod tests { assert!(!is_onion_http("http://abcdef.onion.example.com/x")); } - #[test] - fn a_model_list_is_read_under_v1_of_the_root() { - assert_eq!(provider_models_url("https://openrouter.ai/api"), "https://openrouter.ai/api/v1/models"); - assert_eq!(provider_models_url("https://openrouter.ai/api/"), "https://openrouter.ai/api/v1/models"); - assert_eq!(provider_models_url("https://gw.example/v1"), "https://gw.example/v1/models"); - } - - #[test] - fn a_model_list_keeps_what_names_a_model() { - let body = serde_json::json!({"data": [ - {"id": "a/b", "name": "B"}, - {"id": "c", "display_name": "C", "name": "ignored"}, - {"id": "d", "name": "d"}, - {"id": "a/b"}, - {"id": " "}, - {"id": "bad\u{7}id"}, - {"id": "x".repeat(201)}, - {"no": "id"}, - 7, - ]}); - let ids: Vec<(String, Option)> = parse_provider_models(&body).into_iter().map(|m| (m.id, m.label)).collect(); - assert_eq!(ids, [("a/b".into(), Some("B".into())), ("c".into(), Some("C".into())), ("d".into(), None)]); - assert_eq!(parse_provider_models(&serde_json::json!(["x", "y"])).len(), 2); - assert_eq!(parse_provider_models(&serde_json::json!({"models": [{"id": "z"}]})).len(), 1); - assert!(parse_provider_models(&serde_json::json!({"error": "nope"})).is_empty()); - let many: Vec<_> = (0..250).map(|i| serde_json::json!({"id": format!("m{i}")})).collect(); - assert_eq!(parse_provider_models(&serde_json::json!({"data": many})).len(), MAX_PROVIDER_MODELS); - } - - /// A one-shot HTTP server on loopback: answers the first request with - /// `response` and hands back the request it read. - async fn serve_once(response: &'static str) -> (String, tokio::task::JoinHandle) { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); - let task = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut buf = vec![0; 4096]; - let n = socket.read(&mut buf).await.unwrap(); - socket.write_all(response.as_bytes()).await.unwrap(); - String::from_utf8_lossy(&buf[..n]).to_string() - }); - (base, task) - } - - #[tokio::test] - async fn a_model_list_is_asked_with_the_token_and_never_follows_a_redirect() { - let http = provider_http_client(); - let (base, req) = serve_once( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 22\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m1\"}]}", - ) - .await; - let models = fetch_provider_models(&http, &base, "tok-1").await.unwrap(); - assert_eq!(models, [ProviderModel { id: "m1".into(), label: None }]); - let req = req.await.unwrap().to_lowercase(); - assert!(req.starts_with("get /v1/models "), "{req}"); - assert!(req.contains("authorization: bearer tok-1") && req.contains("x-api-key: tok-1"), "{req}"); - - let (base, _) = serve_once("HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\nconnection: close\r\n\r\n").await; - assert!(fetch_provider_models(&http, &base, "t").await.unwrap_err().contains("refused the token")); - - let (base, _) = - serve_once("HTTP/1.1 302 Found\r\nlocation: http://elsewhere.example/\r\ncontent-length: 0\r\nconnection: close\r\n\r\n").await; - assert!(fetch_provider_models(&http, &base, "t").await.unwrap_err().contains("HTTP 302")); - } - #[tokio::test] async fn git_head_is_none_outside_a_repository() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/client-core/src/bridge_api.rs b/crates/client-core/src/bridge_api.rs index 143c705e..203c256b 100644 --- a/crates/client-core/src/bridge_api.rs +++ b/crates/client-core/src/bridge_api.rs @@ -492,6 +492,7 @@ mod tests { "type": "set-provider-profile", "profileId": "p", "profile": { + "agent": "claude-code", "label": "L", "baseUrl": "http://api.example.com", "authToken": "tok", diff --git a/crates/client-core/src/stores/machines.rs b/crates/client-core/src/stores/machines.rs index a7b1ee2b..cfdb1ac6 100644 --- a/crates/client-core/src/stores/machines.rs +++ b/crates/client-core/src/stores/machines.rs @@ -1847,6 +1847,7 @@ mod tests { machine: "pk".into(), profiles: vec![ProviderProfileInfo { id: "prof1".into(), + agent: "claude-code".into(), label: "Anthropic".into(), base_url: "https://api.example".into(), models: vec![], diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 179ae439..41740b30 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -93,6 +93,7 @@ pub struct UniffiProviderModelWrite { /// UniFFI-crossable mirror of [`protocol::commands::ProviderProfileWrite`]. #[derive(Debug, Clone, uniffi::Record)] pub struct UniffiProviderProfileWrite { + pub agent: String, pub label: String, pub base_url: String, pub auth_token: UniffiTristate, @@ -522,6 +523,7 @@ impl TryFrom for Intent { machine, profile_id, profile: profile.map(|p| ProviderProfileWrite { + agent: p.agent, label: p.label, base_url: p.base_url, auth_token: p.auth_token.into(), @@ -748,6 +750,7 @@ mod tests { machine: "m".into(), profile_id: "kimi-k3".into(), profile: Some(UniffiProviderProfileWrite { + agent: "claude-code".into(), label: "Kimi K3".into(), base_url: "https://api.moonshot.ai/anthropic".into(), auth_token: UniffiTristate::Set { value: "sk-1".into() }, @@ -763,6 +766,7 @@ mod tests { machine: "m".into(), profile_id: "kimi-k3".into(), profile: Some(ProviderProfileWrite { + agent: "claude-code".into(), label: "Kimi K3".into(), base_url: "https://api.moonshot.ai/anthropic".into(), auth_token: Tristate::Set("sk-1".into()), diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index d5416869..3ca5c05c 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -339,6 +339,9 @@ fn to_uniffi_model_entries(models: &[protocol::events::ModelEntry]) -> Vec, @@ -380,6 +383,8 @@ pub struct UniffiAgent { pub supports_models: bool, pub supports_usage: bool, pub supports_providers: bool, + /// The agent's provider profiles add models to its own list. + pub supports_provider_models: bool, pub supports_gsd: bool, pub supports_interrupt: bool, pub supports_commands: bool, @@ -429,6 +434,7 @@ fn to_uniffi_agent(a: &AgentDescriptor) -> UniffiAgent { supports_models: a.supports.models, supports_usage: a.supports.usage, supports_providers: a.supports.providers, + supports_provider_models: a.supports.provider_models, supports_gsd: a.supports.gsd, supports_interrupt: a.supports.interrupt, supports_commands: a.supports.commands, @@ -749,6 +755,7 @@ pub fn build_uniffi_machines_view(v: &MachinesView) -> UniffiMachinesView { .iter() .map(|p| UniffiProviderProfileInfo { id: p.id.clone(), + agent: p.agent.clone(), label: p.label.clone(), base_url: p.base_url.clone(), models: p diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 6bbef1ff..2ffe748c 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -51,9 +51,9 @@ { "type": "pair-request", "npub": "npub1x", "pubkeyHex": "aa", "label": "phone", "token": "t" }, { "type": "pair-request", "npub": "npub1x", "pubkeyHex": "aa", "label": "phone", "token": "t", "sessionKey": { "pubkeyHex": "bb", "bridgePubkeyHex": "cc", "expiresAt": 1800000000 } }, { "type": "set-provider-profile", "profileId": "p", "profile": null }, - { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "https://api.example.com/v1", "authToken": "tok", "models": [{ "id": "m1", "label": "M1" }], "defaultModel": "m1" } }, - { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "http://localhost:11434/v1", "models": [{ "id": "m1" }] } }, - { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "https://openrouter.ai/api", "authToken": "tok", "models": [], "modelsFromProvider": true, "defaultModel": "m1" } }, + { "type": "set-provider-profile", "profileId": "p", "profile": { "agent": "claude-code", "label": "L", "baseUrl": "https://api.example.com/v1", "authToken": "tok", "models": [{ "id": "m1", "label": "M1" }], "defaultModel": "m1" } }, + { "type": "set-provider-profile", "profileId": "p", "profile": { "agent": "claude-code", "label": "L", "baseUrl": "http://localhost:11434/v1", "models": [{ "id": "m1" }] } }, + { "type": "set-provider-profile", "profileId": "p", "profile": { "agent": "claude-code", "label": "L", "baseUrl": "https://openrouter.ai/api", "authToken": "tok", "models": [], "modelsFromProvider": true, "defaultModel": "m1" } }, { "type": "provider-profiles-request" }, { "type": "session-key", "sessionKey": { "pubkeyHex": "bb", "bridgePubkeyHex": "cc", "expiresAt": 1800000000 } } ], @@ -89,7 +89,7 @@ { "type": "sessions", "machine": "m", "sessions": [], "agents": [], "protocolVersion": 11, "rev": 1790000000000 }, { "type": "sessions", "machine": "m", "sessions": [], "agents": [], "protocolVersion": 11, "direct": { "endpoints": ["wss://192.168.1.20:7447", "ws://abc.onion:7448"], "certSha256": "00ff" } }, { "type": "sessions", "machine": "m", "sessions": [], "agents": [], "protocolVersion": 11, "direct": { "endpoints": ["ws://abc.onion:7448"] } }, - { "type": "sessions", "machine": "m", "host": "service", "sessions": [{ "id": "s", "agent": "opencode", "slug": "sl", "cwd": "/w", "lastActivity": "t", "lineCount": 1, "title": null, "project": "p", "mode": "build", "effort": "high", "state": "running", "seqHigh": 10 }], "agents": [{ "id": "claude-code", "displayName": "Claude Code", "modes": [{ "id": "default", "label": "Default" }, { "id": "plan", "label": "Plan", "description": "Read-only planning" }], "efforts": [{ "id": "high", "label": "High" }], "defaultMode": "default", "defaultEffort": "high", "supports": { "models": true, "usage": true, "providers": true, "gsd": true, "interrupt": true, "commands": true, "plugins": true }, "credentials": [{ "id": "anthropic_api_key", "label": "Anthropic API key", "present": true, "fromEnv": true }] }, { "id": "opencode", "displayName": "OpenCode" }], "credentials": [{ "id": "github_pat", "label": "GitHub token", "present": false }], "protocolVersion": 11, "capabilities": ["sync/1", "files", "chunked"], "folders": ["a", "b"], "roots": ["/w"], "removedSessions": ["old"], "machineOffline": true }, + { "type": "sessions", "machine": "m", "host": "service", "sessions": [{ "id": "s", "agent": "opencode", "slug": "sl", "cwd": "/w", "lastActivity": "t", "lineCount": 1, "title": null, "project": "p", "mode": "build", "effort": "high", "state": "running", "seqHigh": 10 }], "agents": [{ "id": "claude-code", "displayName": "Claude Code", "modes": [{ "id": "default", "label": "Default" }, { "id": "plan", "label": "Plan", "description": "Read-only planning" }], "efforts": [{ "id": "high", "label": "High" }], "defaultMode": "default", "defaultEffort": "high", "supports": { "models": true, "usage": true, "providers": true, "gsd": true, "interrupt": true, "commands": true, "plugins": true }, "credentials": [{ "id": "anthropic_api_key", "label": "Anthropic API key", "present": true, "fromEnv": true }] }, { "id": "opencode", "displayName": "OpenCode", "supports": { "models": true, "providerModels": true } }], "credentials": [{ "id": "github_pat", "label": "GitHub token", "present": false }], "protocolVersion": 11, "capabilities": ["sync/1", "files", "chunked"], "folders": ["a", "b"], "roots": ["/w"], "removedSessions": ["old"], "machineOffline": true }, { "type": "output", "sessionId": "s", "seq": 1, "entries": [{ "timestamp": "2026-08-05T00:00:00.000Z", "entryType": "text", "role": "user", "text": "hello" }] }, { "type": "output", "sessionId": "s", "seq": 19, "entries": [{ "timestamp": "t", "entryType": "tool_call", "callId": "c3", "toolName": "Read", "kind": "read", "title": "a.rs" }, { "timestamp": "t", "entryType": "tool_result", "callId": "c3", "text": "fn main() {}" }, { "timestamp": "t", "entryType": "turn_complete" }] }, { "type": "output", "sessionId": "s", "seq": 2, "entries": [{ "timestamp": "t", "entryType": "text", "role": "agent", "text": "Running the tests." }] }, @@ -148,7 +148,7 @@ { "type": "credentials-ack", "machine": "m", "success": false, "credentials": [], "error": "not writable" }, { "type": "pair-ack", "machine": "m", "ok": true }, { "type": "pair-ack", "machine": "m", "ok": false, "reason": "bad-token", "relays": ["wss://r"], "host": "cli" }, - { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }], "modelsFromProvider": true, "hasToken": true }] }, + { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "agent": "claude-code", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "agent": "opencode", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }], "modelsFromProvider": true, "hasToken": true }] }, { "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false } ], "rejected": [ diff --git a/crates/protocol/src/codec.rs b/crates/protocol/src/codec.rs index 859604de..26c58c7f 100644 --- a/crates/protocol/src/codec.rs +++ b/crates/protocol/src/codec.rs @@ -143,6 +143,7 @@ mod tests { version: VersionFields::default(), profile_id: "p".into(), profile: Some(ProviderProfileWrite { + agent: "claude-code".into(), label: "L".into(), base_url: "http://api.example.com".into(), auth_token: Tristate::Set("tok".into()), @@ -160,6 +161,7 @@ mod tests { version: VersionFields::default(), profile_id: "p".into(), profile: Some(ProviderProfileWrite { + agent: "claude-code".into(), label: "L".into(), base_url: "http://localhost:11434/v1".into(), auth_token: Tristate::Keep, diff --git a/crates/protocol/src/commands.rs b/crates/protocol/src/commands.rs index ec5f50cf..5d1d2bd8 100644 --- a/crates/protocol/src/commands.rs +++ b/crates/protocol/src/commands.rs @@ -363,6 +363,9 @@ pub struct SessionKeyMsg { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProviderProfileWrite { + /// The agent the profile is for (one whose catalog entry `supports` + /// `providers` or `providerModels`). + pub agent: String, pub label: String, /// CDX-071: https, or http ONLY on loopback — validated on egress /// ([`super::codec::encode_phone_to_bridge`]). @@ -528,7 +531,7 @@ mod tests { let m = rt(&json!({"type":"set-provider-profile","profileId":"p","profile":null})); assert!(matches!(m, PhoneToBridge::SetProviderProfile(SetProviderProfileMsg { profile: None, .. }))); let m = rt(&json!({"type":"set-provider-profile","profileId":"p","profile":{ - "label":"L","baseUrl":"https://api.x","authToken":"tok","models":[{"id":"m1"}] + "agent":"claude-code","label":"L","baseUrl":"https://api.x","authToken":"tok","models":[{"id":"m1"}] }})); match m { PhoneToBridge::SetProviderProfile(s) => { diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index 7e0128a8..f7fe68d6 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -39,9 +39,15 @@ pub struct AgentSupports { /// `usage-request` returns subscription usage for this agent's sessions. #[serde(default)] pub usage: bool, - /// Sessions may be bound to a custom provider profile (`providerId`). + /// Sessions may be bound to one of this agent's provider profiles + /// (`providerId`), which then serves the whole session. #[serde(default)] pub providers: bool, + /// This agent's provider profiles add their models to its own model + /// list, beside every provider it already has; a session picks one of + /// them as it would any other model. + #[serde(default)] + pub provider_models: bool, /// `gsd-request` returns GSD workflow state for this agent's sessions. #[serde(default)] pub gsd: bool, @@ -513,7 +519,25 @@ fn plain_http_host(raw: &str) -> PlainHttp { } else { authority.rsplit_once(':').map_or(authority, |(h, _)| h) }; - matches!(host.to_ascii_lowercase().as_str(), "localhost" | "127.0.0.1") + PlainHttp::Host(host.to_ascii_lowercase()) +} + +/// Is `host` an IP address on a private network (see +/// [`is_valid_provider_base_url`])? Only the canonical spelling counts: +/// std's parser refuses the shorthands and leading zeros a WHATWG parser +/// would read as another address (`010.0.0.1` is 8.0.0.1 there). +fn is_private_network_address(host: &str) -> bool { + if let Ok(v4) = host.parse::() { + let [a, b, ..] = v4.octets(); + return a == 10 + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) + || (a == 100 && (64..=127).contains(&b)); + } + if let Ok(v6) = host.parse::() { + return v6.segments()[0] & 0xfe00 == 0xfc00; + } + false } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] @@ -1012,6 +1036,43 @@ mod tests { assert!(!is_valid_provider_base_url("https://")); } + #[test] + fn a_provider_may_be_on_the_users_own_network() { + for ok in [ + "http://192.168.1.2:3458", + "http://10.0.0.7/v1", + "http://172.16.0.1", + "http://172.31.255.254:80", + "http://100.101.102.103:8080", + "http://[fd12:3456::1]:3000/v1", + "http://[FC00::1]", + ] { + assert!(is_valid_provider_base_url(ok), "{ok}"); + assert!(!is_https_or_loopback_url(ok), "{ok}"); + } + for refused in [ + "http://172.32.0.1", + "http://192.169.0.1", + "http://100.128.0.1", + "http://8.8.8.8", + "http://[2001:db8::1]", + "http://[fe80::1]", + // Spellings a WHATWG parser reads as another address. + "http://010.0.0.1", + "http://192.168.1", + "http://0xc0.168.1.1", + // A name could point anywhere. + "http://router.local", + "http://192.168.1.2.nip.io", + "http://192.168.1.2@evil.com", + ] { + assert!(!is_valid_provider_base_url(refused), "{refused}"); + } + assert!(is_https_or_loopback_url("https://relay.example")); + assert!(is_https_or_loopback_url("http://localhost:8080")); + assert!(!is_https_or_loopback_url("http://[::2]")); + } + #[test] fn provider_base_url_rule_refuses_what_a_whatwg_parser_reads_differently() { assert!(is_valid_provider_base_url("http://LOCALHOST:8080")); diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 30ade0e4..80ba44e5 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -545,7 +545,7 @@ mod tests { rt(&json!({"type":"credentials-ack","machine":"m","agent":"claude-code","success":true, "credentials":[{"id":"anthropic_api_key","label":"Anthropic API key","present":true,"valid":true}]})); rt(&json!({"type":"pair-ack","machine":"m","ok":false,"reason":"bad-token","relays":["wss://r"],"host":"cli"})); - rt(&json!({"type":"provider-profiles","machine":"m","profiles":[{"id":"p","label":"L","baseUrl":"https://x","models":[{"id":"m"}],"hasToken":true}]})); + rt(&json!({"type":"provider-profiles","machine":"m","profiles":[{"id":"p","agent":"claude-code","label":"L","baseUrl":"https://x","models":[{"id":"m"}],"hasToken":true}]})); rt(&json!({"type":"provider-profile-ack","machine":"m","profileId":"p","success":true,"tokenValid":false})); } diff --git a/docs/AGENT-CANDIDATES.md b/docs/AGENT-CANDIDATES.md index 802dbb5c..10d31d66 100644 --- a/docs/AGENT-CANDIDATES.md +++ b/docs/AGENT-CANDIDATES.md @@ -97,6 +97,17 @@ catalog entry that runs Pi with our extensions (permission gate, question and plan-review tools, a short system prompt). Decide after the Pi driver exists, when it costs one extension package rather than a project. +### The default agent: OpenCode + +Decided 2026-10-08. A default has to work with any OpenAI-compatible +provider or gateway, take its models from the endpoint's `/v1/models`, and +offer no tool that only works on its vendor's API. OpenCode meets all +three: its web search is offered only on its own provider (checked on +1.18.32), its web fetch is local, and a provider profile adds the +endpoint's models to its list beside OpenCode Zen's free ones — read from +the endpoint, never typed by hand. Codex is out (its client speaks only the +Responses API); a Pi-based entry stays an option once the Pi driver exists. + ## Installing agents on demand Today every driver the host is told to load (`CODEDECK_AGENT_HOST_DRIVERS`, diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 0718f314..642a7f38 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -61,7 +61,7 @@ event-id set alongside the cursor so the replay is a no-op. Nothing in the wire names a particular coding agent. The heartbeat carries `agents: AgentDescriptor[]` — per agent its `id`, `displayName`, `modes[]`, `efforts[]`, `defaultMode`, `defaultEffort`, `supports {models, usage, -providers, gsd, interrupt, commands, plugins, mcp, tasks}` and `credentials[]` status. Phones build every +providers, providerModels, gsd, interrupt, commands, plugins, mcp, tasks}` and `credentials[]` status. Phones build every picker from it and offer a feature only when the session's agent `supports` it. Mode, effort and model values are opaque strings the bridge validates against the catalog. @@ -210,24 +210,40 @@ this message, inbound; status only ever goes out.** ### Custom provider profiles -Phone-managed, bridge-stored profiles that point a session at another -endpoint: a provider's own API, or a gateway in front of several. Which wire -the endpoint must speak is the agent's (Claude Code: Anthropic's Messages -API; OpenCode: OpenAI's chat completions). Only agents with -`supports.providers` accept one. +Phone-managed, bridge-stored profiles of another endpoint: a provider's own +API, or a gateway in front of several. Each is for one `agent` — the +endpoint must speak the API that agent uses, and one that speaks one +agent's need not speak another's. What a profile does is the agent's +catalog entry: + +- `supports.providers`: a session can be bound to one of the agent's + profiles (`create-session.providerId`), which then serves the whole + session; +- `supports.providerModels`: the agent's profiles add their models to its + own model list, beside every provider it already has; a session picks one + as any other model, and is never bound. + +Messages: - `set-provider-profile {profileId, profile | null}`: upsert or delete (`null` - deletes). `profile.authToken` is tri-state: absent = keep, `null` = clear, - string = set. The base URL must be https (http only on loopback) — the - bridge never stores an insecure profile, and refuses to start a session on - one written before that rule. -- `profile.modelsFromProvider: true`: the phone lists no models; the bridge - reads them from the endpoint's `/v1/models` (`/models` when the base URL - already ends in `/v1`) with the profile's token, on every save, and stores - at most 200. It follows no redirect, and refuses the save when the list - cannot be read or is empty. A `defaultModel` the list does not name is - dropped. The stored profile reports the flag back, so a later save (with - the token kept) reads the list again. + deletes). `profile.agent` names an agent with either flag. `profile.authToken` + is tri-state: absent = keep, `null` = clear, string = set. The base URL must + be https, or http to this machine (`localhost`, `127.0.0.1`, `[::1]`) or to + an IP address of the user's own network (10/8, 172.16/12, 192.168/16, + 100.64/10, fc00::/7 — addresses, never names) — the bridge never stores + another, and refuses to start a session on one written before that rule. +- `profile.modelsFromProvider: true`: the phone lists no models; the agent + host reads them from the endpoint's `/v1/models` (`/models` when the base + URL already ends in `/v1`), signing in as the profile's agent does, with + the profile's token, on every save, and the bridge stores at most 200. No + redirect is followed, and the save is refused when the list cannot be read + or is empty. A `defaultModel` the list does not name is dropped. The stored + profile reports the flag back, so a later save (with the token kept) reads + the list again. +- The token is checked by the profile's agent, with the smallest request on + the API it speaks (`tokenValid` in the ack). +- A profile stored before profiles named their agent has an empty `agent`: + no agent uses it until a save names one. - `provider-profiles-request` → `provider-profiles {profiles[]}` to the asking phone; after every change the bridge broadcasts the new list to all phones. - `provider-profile-ack {profileId, success, tokenValid?, error?}`; @@ -498,6 +514,9 @@ The bridge's ids are `b1, b2, …`; the host's are `h1, h2, …`. | `session-mcp {sessionId}` | `session-mcp {servers, toggles, projectWide}` | | `session-mcp-toggle {sessionId, name, enabled}` | `session-mcp {…}` once done, or `error` | | `check-credential {agent, credential, value}` | `credential-checked {valid?}` | +| `check-provider {agent, provider, model}` | `credential-checked {valid?}`: a provider profile's token, checked on the API `agent` speaks | +| `list-provider-models {agent, baseUrl, authToken}` | `provider-models {models}` (never empty), read the way `agent` signs in; or `error` with the reason there is none | +| `set-providers {agent, providers}` | `ack` once an agent with `supports.providerModels` offers these profiles' models (all of its profiles, sent after `initialize` and on every change), or `error` | `AgentInfo` is the catalog entry minus credential status (the bridge adds that), plus `credentials[].envVar` and `unavailableReason`. @@ -544,6 +563,11 @@ restart. `requestPlanApproval()` when the user must decide. 2. Translate the agent's own events into typed `OutputEntry` values in the driver — nothing agent-specific may reach the bridge. + For provider profiles, say in `supports` how the agent uses one + (`providers`: bound to a session through `StartSession.provider`; + `providerModels`: added to its models through `setProviders()`), and + implement `listProviderModels()` and `checkProvider()` with the API the + agent speaks to an endpoint (`src/sdk/providerApi.ts`). 3. Export a `DriverModule` (`src/sdk/module.ts`) from the folder's `module.ts`: the agent's id, how its driver is built from the environment, and its runtime (what the machine already has, and how to diff --git a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts index 6dc1c8fc..8f6ea6f5 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts @@ -471,12 +471,12 @@ describe('Claude options and setup', () => { it('a provider binding that must not be used refuses the session before anything starts', () => { const facade = new ScriptedFacade(); - expect(() => start({ provider: { id: 'p', baseUrl: 'http://remote.example', authToken: 't', models: [] } }, {}, facade)).toThrow(/insecure base URL/); + expect(() => start({ provider: { id: 'p', label: 'p', baseUrl: 'http://remote.example', authToken: 't', models: [] } }, {}, facade)).toThrow(/insecure base URL/); expect(facade.sessions).toHaveLength(0); }); it('a provider-bound session never falls back to an Anthropic model', () => { - const { facade } = start({ provider: { id: 'kimi', baseUrl: 'https://api.moonshot.ai/anthropic', authToken: 't', models: [{ id: 'kimi-k3' }] } }); + const { facade } = start({ provider: { id: 'kimi', label: 'kimi', baseUrl: 'https://api.moonshot.ai/anthropic', authToken: 't', models: [{ id: 'kimi-k3' }] } }); expect(facade.last.opts).toMatchObject({ providerId: 'kimi', fallbackModel: null }); expect(facade.last.opts.env?.ANTHROPIC_AUTH_TOKEN).toBe('t'); }); @@ -686,7 +686,7 @@ describe('Claude model discovery', () => { expect(chosen.facade.last.opts.model).toBe('claude-sonnet-5'); expect(chosen.ctx.events.some((e) => e.type === 'info')).toBe(false); - const bound = start({ provider: { id: 'p', baseUrl: 'https://x', authToken: 't', models: [] } }); + const bound = start({ provider: { id: 'p', label: 'p', baseUrl: 'https://x', authToken: 't', models: [] } }); await bound.ctx.waitFor((e) => e.type === 'ready'); expect(bound.facade.last.opts.model).toBeUndefined(); }); @@ -717,7 +717,7 @@ describe('Claude model checks', () => { }); it('holds a provider-bound session to its profile', () => { - const provider = { id: 'kimi', baseUrl: 'https://x', authToken: 't', models: [{ id: 'kimi-k3' }] }; + const provider = { id: 'kimi', label: 'kimi', baseUrl: 'https://x', authToken: 't', models: [{ id: 'kimi-k3' }] }; expect(unsupportedModelReason('kimi-k3', [], provider)).toBeUndefined(); expect(unsupportedModelReason('claude-opus-5-5', known, provider)).toMatch(/profile 'kimi' does not offer/); }); diff --git a/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts b/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts index 52ed62ca..5d9bcf68 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts @@ -16,6 +16,7 @@ const KIMI_TOKEN = 'sk-kimi-TESTSECRET-000'; function kimi(over: Partial = {}): ProviderBinding { return { id: 'kimi', + label: 'Kimi', baseUrl: 'https://api.moonshot.ai/anthropic', authToken: KIMI_TOKEN, models: [{ id: 'kimi-k3', label: 'Kimi K3' }, { id: 'kimi-k3-turbo' }], diff --git a/packages/agent-host/src/drivers/claude/driver.ts b/packages/agent-host/src/drivers/claude/driver.ts index 0c4d057e..f4e63d8a 100644 --- a/packages/agent-host/src/drivers/claude/driver.ts +++ b/packages/agent-host/src/drivers/claude/driver.ts @@ -15,6 +15,9 @@ import * as path from 'node:path'; import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../sdk/driver'; import { mcpStatus } from '../../sdk/mcp'; import type { HttpPost } from '../../sdk/net'; +import type { ProviderHttp } from '../../sdk/net'; +import { ANTHROPIC_API, checkToken, listModels } from '../../sdk/providerApi'; +import type { EndpointModel } from '../../sdk/providerModels'; import { isBenignPlanDirWrite } from './policy'; import { PERMISSION_ALLOW, PERMISSION_ALLOW_ALWAYS, PERMISSION_DENY, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; import { newTranslateContext } from '../../sdk/transcript'; @@ -150,6 +153,8 @@ export interface ClaudeDriverDeps { installClaude?: () => Promise; /** Outbound HTTP for the API-key check. */ httpPost?: HttpPost; + /** Outbound HTTP to provider profiles' endpoints. */ + providerHttp?: ProviderHttp; /** Spawn a throwaway session for the model list when no live session can * answer it — and once at start, so the list is ready before any session * exists. */ @@ -846,6 +851,17 @@ export class ClaudeDriver implements Driver { } } + /** A profile's endpoint is Claude Code's Anthropic API: a session on it + * posts to its Messages API. */ + async checkProvider(provider: ProviderBinding, model: string): Promise { + return this.options.providerHttp ? checkToken(ANTHROPIC_API, provider, model, this.options.providerHttp.post) : undefined; + } + + async listProviderModels(baseUrl: string, token: string): Promise { + if (!this.options.providerHttp) throw new Error('this host reads no provider endpoints'); + return listModels(ANTHROPIC_API, baseUrl, token, this.options.providerHttp.get); + } + deleteConversation(conversationId: string, cwd: string): Promise { return this.options.facade.deleteSession(conversationId, cwd); } diff --git a/packages/agent-host/src/drivers/claude/module.ts b/packages/agent-host/src/drivers/claude/module.ts index 319f109b..27ea3f7e 100644 --- a/packages/agent-host/src/drivers/claude/module.ts +++ b/packages/agent-host/src/drivers/claude/module.ts @@ -6,7 +6,7 @@ */ import { installBinary } from '../../install/agentInstall'; import type { DriverEnv, DriverModule } from '../../sdk/module'; -import { httpPost } from '../../sdk/net'; +import { httpPost, providerHttp } from '../../sdk/net'; import { ClaudeDriver } from './driver'; import { RealSdkFacade, resolveClaudeExecutable } from './facade'; import { bundledClaudeExecutable, claudeBinary } from './install'; @@ -36,6 +36,7 @@ export const claudeModule: DriverModule = { // so the agent is listed right away and the first session waits for it. ...(!test && !claudePath ? { installClaude: () => install(ctx) } : {}), httpPost, + providerHttp, discoverModels: !test, managePlugins: !test, manageMcp: !test, diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts index 5767aa8e..7e7d7b96 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts @@ -46,8 +46,8 @@ describe('the model list the endpoint serves', () => { throw new Error('ECONNREFUSED'); }, log), ).toBeUndefined(); - expect(logs.some((line) => /answered 401/.test(line))).toBe(true); - expect(logs.some((line) => /listed no models/.test(line))).toBe(true); + expect(logs.some((line) => /refused the token \(HTTP 401\)/.test(line))).toBe(true); + expect(logs.some((line) => /lists no models/.test(line))).toBe(true); expect(logs.some((line) => /ECONNREFUSED/.test(line))).toBe(true); }); }); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts index 867ea428..e65fac4d 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts @@ -155,7 +155,7 @@ describe('DeepSeekSession startup', () => { const ready = withDriver(); const ctx = await started(ready, { model: 'kimi-k2', - provider: { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-x', models: [] }, + provider: { id: 'p1', label: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-x', models: [] }, }); expect(ready.harness.setOptions.some((option) => option.configId === 'model')).toBe(true); expect(ctx.entries().some((entry) => entry.entryType === 'error' && /kimi-k2/.test(entry.text))).toBe(true); @@ -192,7 +192,7 @@ describe('DeepSeekSession environment', () => { it('hands the session its provider binding, replacing the harness namespace', async () => { const ready = withDriver({ env: { PATH: '/bin', DEEPSEEK_API_KEY: 'native-key', KEEP: 'yes' } }); await started(ready, { - provider: { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-gateway', models: [] }, + provider: { id: 'p1', label: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-gateway', models: [] }, }); expect(ready.spawns[0]?.env.DEEPSEEK_BASE_URL).toBe('https://gateway.example/v1'); expect(ready.spawns[0]?.env.DEEPSEEK_API_KEY).toBe('sk-gateway'); @@ -210,7 +210,7 @@ describe('DeepSeekSession environment', () => { const ctx = recordingContext(); expect(() => ready.driver.startSession( - start({ provider: { id: 'p1', baseUrl: 'http://gateway.example/v1', authToken: 'sk', models: [] } }), + start({ provider: { id: 'p1', label: 'p1', baseUrl: 'http://gateway.example/v1', authToken: 'sk', models: [] } }), ctx, ), ).toThrow(/insecure base URL/); @@ -220,7 +220,7 @@ describe('DeepSeekSession environment', () => { const ready = withDriver(); await started(ready, { sessionId: 'b1' }); await started(ready, { sessionId: 'b2' }); - const gateway = { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk', models: [] }; + const gateway = { id: 'p1', label: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk', models: [] }; await started(ready, { sessionId: 'b3', provider: gateway }); expect(ready.spawns).toHaveLength(2); }); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts index e496d3b2..efcbe4e7 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts @@ -23,6 +23,7 @@ const BASE: Record = { const provider = (overrides: Partial = {}): ProviderBinding => ({ id: 'gateway', + label: 'Gateway', baseUrl: 'https://gateway.example/v1', authToken: 'sk-gateway', models: [], diff --git a/packages/agent-host/src/drivers/deepseek/driver.ts b/packages/agent-host/src/drivers/deepseek/driver.ts index 0a31ae9e..50fb6641 100644 --- a/packages/agent-host/src/drivers/deepseek/driver.ts +++ b/packages/agent-host/src/drivers/deepseek/driver.ts @@ -24,7 +24,9 @@ */ import type { RequestPermissionRequest, RequestPermissionResponse, SessionConfigOption, SessionNotification } from '@agentclientprotocol/sdk'; import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../sdk/driver'; -import type { HttpGet } from '../../sdk/net'; +import type { HttpGet, ProviderHttp } from '../../sdk/net'; +import { ANTHROPIC_API, checkToken, listModels } from '../../sdk/providerApi'; +import type { EndpointModel } from '../../sdk/providerModels'; import { mcpStatus as mcpStatusOf } from '../../sdk/mcp'; import { parseSlashCommand } from '../../sdk/commands'; import { PERMISSION_ALLOW, PERMISSION_DENY, now, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; @@ -33,6 +35,7 @@ import type { McpStatus, ModelEntry, OutputEntry, + ProviderBinding, SessionMcpServer, SessionOption, SlashCommand, @@ -789,6 +792,8 @@ export interface DeepSeekDriverOptions { baseEnv?: NodeJS.ProcessEnv; /** HTTPS client for the credential check. */ httpGet?: HttpGet; + /** Outbound HTTP to provider profiles' endpoints. */ + providerHttp?: ProviderHttp; /** Spawn seam for the plugin commands, for tests. */ spawnFn?: SpawnFn; log: (message: string) => void; @@ -1008,6 +1013,17 @@ export class DeepSeekDriver implements Driver { } } + /** The harness reaches a profile's endpoint through its DeepSeek route, + * which speaks Anthropic's Messages API under the endpoint's `/v1`. */ + async checkProvider(provider: ProviderBinding, model: string): Promise { + return this.options.providerHttp ? checkToken(ANTHROPIC_API, provider, model, this.options.providerHttp.post) : undefined; + } + + async listProviderModels(baseUrl: string, token: string): Promise { + if (!this.options.providerHttp) throw new Error('this host reads no provider endpoints'); + return listModels(ANTHROPIC_API, baseUrl, token, this.options.providerHttp.get); + } + deleteConversation(conversationId: string): Promise { return deleteDshConversation(this.options.home, conversationId); } diff --git a/packages/agent-host/src/drivers/deepseek/module.ts b/packages/agent-host/src/drivers/deepseek/module.ts index ad8458db..39b017bf 100644 --- a/packages/agent-host/src/drivers/deepseek/module.ts +++ b/packages/agent-host/src/drivers/deepseek/module.ts @@ -5,7 +5,7 @@ */ import type { DriverEnv, DriverModule } from '../../sdk/module'; import { isFile } from '../../sdk/executable'; -import { httpGet } from '../../sdk/net'; +import { httpGet, providerHttp } from '../../sdk/net'; import { DeepSeekDriver } from './driver'; import { takeDeepSeekEnv } from './env'; import { installDshTree } from './install'; @@ -43,6 +43,7 @@ export const deepSeekModule: DriverModule = { mcp, baseEnv: ctx.ownEnv, httpGet, + providerHttp, log: ctx.log, }); if (dshPath && !isFile(dshPath)) { diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts deleted file mode 100644 index 6ee7b4e8..00000000 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProfiles.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Provider-profile sessions: the server each profile runs on (its config, - * its token, its password, its lifetime) and how a session names the - * profile's models. - */ -import { describe, expect, it, vi } from 'vitest'; -import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2/client'; -import { OpenCodeDriver } from '../driver'; -import { PROFILE_PROVIDER_ID, PROFILE_TOKEN_ENV, ProfileServers, profileConfig, type StartedServer } from '../profiles'; -import type { ProviderBinding } from '../../../sdk/types'; -import { recordingContext } from '../../../sdk/__tests__/context'; - -const router = (over: Partial = {}): ProviderBinding => ({ - id: 'router', - baseUrl: 'https://router.example/api', - authToken: 'tok-secret', - models: [{ id: 'Z.ai/glm-5', label: 'GLM 5' }, { id: 'kimi-k3' }], - defaultModel: 'kimi-k3', - ...over, -}); - -/** A fake `opencode serve`: records each start, exits when told. */ -function fakeServers(idleMs = 1_000) { - const starts: Array<{ env: Record; exit: () => void; closed: boolean }> = []; - const connects: Array<{ url: string; headers: Record }> = []; - const servers = new ProfileServers({ - start: async (env): Promise => { - let exit!: () => void; - const exited = new Promise((done) => (exit = done)); - const record = { env, exit, closed: false }; - starts.push(record); - return { - url: `http://127.0.0.1:${4100 + starts.length}`, - exited, - close: async () => { - record.closed = true; - exit(); - }, - }; - }, - log: () => {}, - idleMs, - connect: (url, headers) => { - connects.push({ url, headers }); - return { url } as unknown as OpencodeClient; - }, - }); - return { servers, starts, connects }; -} - -describe('profileConfig', () => { - it('enables only the profile, on its own models, with the token left to the environment', () => { - const config = profileConfig(router()); - expect(config).toEqual({ - enabled_providers: [PROFILE_PROVIDER_ID], - model: `${PROFILE_PROVIDER_ID}/kimi-k3`, - small_model: `${PROFILE_PROVIDER_ID}/kimi-k3`, - provider: { - [PROFILE_PROVIDER_ID]: { - npm: '@ai-sdk/openai-compatible', - name: 'router', - options: { baseURL: 'https://router.example/api/v1', apiKey: `{env:${PROFILE_TOKEN_ENV}}` }, - models: { 'Z.ai/glm-5': { name: 'GLM 5' }, 'kimi-k3': {} }, - }, - }, - }); - expect(JSON.stringify(config)).not.toContain('tok-secret'); - // A base URL that already names its version is not given another. - expect(JSON.stringify(profileConfig(router({ baseUrl: 'https://router.example/v1/' })))).toContain('"baseURL":"https://router.example/v1"'); - }); -}); - -describe('ProfileServers', () => { - it('starts one password-guarded server per profile and shares it', async () => { - const { servers, starts, connects } = fakeServers(); - const a = servers.acquire(router()); - const b = servers.acquire(router()); - await Promise.all([a.client, b.client]); - expect(starts).toHaveLength(1); - const env = starts[0]!.env; - expect(env[PROFILE_TOKEN_ENV]).toBe('tok-secret'); - expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT!)).toEqual(profileConfig(router())); - const password = env.OPENCODE_SERVER_PASSWORD!; - expect(password).toMatch(/^[0-9a-f]{48}$/); - expect(connects[0]!.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); - - // Another token is another server, with another password. - await servers.acquire(router({ authToken: 'tok-2' })).client; - expect(starts).toHaveLength(2); - expect(starts[1]!.env.OPENCODE_SERVER_PASSWORD).not.toBe(password); - await servers.closeAll(); - expect(starts.every((s) => s.closed)).toBe(true); - }); - - it('keeps a server a while after its last session, then stops it', async () => { - vi.useFakeTimers(); - try { - const { servers, starts } = fakeServers(1_000); - const lease = servers.acquire(router()); - await lease.client; - lease.release(); - lease.release(); - await vi.advanceTimersByTimeAsync(500); - // Picked up again within the grace period: the same server. - const again = servers.acquire(router()); - await vi.advanceTimersByTimeAsync(2_000); - expect(starts[0]!.closed).toBe(false); - again.release(); - await vi.advanceTimersByTimeAsync(1_000); - expect(starts[0]!.closed).toBe(true); - await servers.acquire(router()).client; - expect(starts).toHaveLength(2); - } finally { - vi.useRealTimers(); - } - }); - - it('forgets a server that exited, and one that failed to start', async () => { - const { servers, starts } = fakeServers(); - await servers.acquire(router()).client; - starts[0]!.exit(); - await new Promise((r) => setTimeout(r, 0)); - await servers.acquire(router()).client; - expect(starts).toHaveLength(2); - - const failing = new ProfileServers({ - start: vi.fn().mockRejectedValueOnce(new Error('no opencode')).mockResolvedValue({ url: 'http://x', exited: new Promise(() => {}), close: async () => {} }), - log: () => {}, - connect: () => ({}) as OpencodeClient, - }); - await expect(failing.acquire(router()).client).rejects.toThrow('no opencode'); - await expect(failing.acquire(router()).client).resolves.toBeDefined(); - }); -}); - -describe('a session bound to a provider profile', () => { - function profileClient() { - async function* stream() { - await new Promise((r) => setTimeout(r, 20)); - } - return { - event: { subscribe: vi.fn().mockResolvedValue({ stream: stream() }) }, - session: { - create: vi.fn().mockResolvedValue({ data: { id: 'ses_p' } as Session, error: undefined }), - get: vi.fn(), - abort: vi.fn().mockResolvedValue({ data: true, error: undefined }), - prompt: vi.fn().mockResolvedValue({ data: {}, error: undefined }), - promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }), - }, - } as unknown as OpencodeClient; - } - - function driverWith(client: OpencodeClient) { - const started: Array> = []; - const servers = new ProfileServers({ - start: async (env) => { - started.push(env); - return { url: 'http://127.0.0.1:4199', exited: new Promise(() => {}), close: async () => {} }; - }, - log: () => {}, - connect: () => client, - }); - // The machine's own server is never asked. - const machine = { event: { subscribe: vi.fn() } } as unknown as OpencodeClient; - return { driver: OpenCodeDriver.withClient(machine, servers), started, machine }; - } - - it("runs on the profile's server, under the profile's model ids", async () => { - const client = profileClient(); - const { driver, started, machine } = driverWith(client); - expect(driver.info().supports?.providers).toBe(true); - const ctx = recordingContext(); - driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/w', provider: router() }, ctx); - await ctx.ended(); - expect(started).toHaveLength(1); - expect(machine.event.subscribe).not.toHaveBeenCalled(); - // The default model, as the profile names it. - expect(ctx.events).toContainEqual({ type: 'info', nativeSessionId: 'ses_p', model: 'kimi-k3', mode: 'ask' }); - }); - - it('is refused for a model the profile does not offer, an insecure URL or no token', () => { - const { driver } = driverWith(profileClient()); - const start = (provider: ProviderBinding, model?: string) => - driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/w', provider, ...(model ? { model } : {}) }, recordingContext()); - expect(() => start(router(), 'gpt-9')).toThrow(/does not offer the model 'gpt-9'/); - expect(() => start(router({ baseUrl: 'http://router.example' }))).toThrow(/insecure base URL/); - expect(() => start(router({ authToken: '' }))).toThrow(/no stored auth token/); - // A model id with a slash is the profile's own, not provider/model. - expect(() => start(router(), 'Z.ai/glm-5')).not.toThrow(); - }); - - it("switches only among the profile's models", async () => { - const { driver } = driverWith(profileClient()); - const ctx = recordingContext(); - const session = driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/w', provider: router() }, ctx); - await ctx.waitFor((e) => e.type === 'ready'); - await expect(session.setOption('model', 'Z.ai/glm-5')).resolves.toBeUndefined(); - await expect(session.setOption('model', 'anthropic/claude-x')).rejects.toThrow(); - await session.end(); - }); -}); diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts new file mode 100644 index 00000000..37a11fa2 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -0,0 +1,138 @@ +/** + * Provider profiles as OpenCode providers: the config the server starts + * with, the password guarding it, and the restart a changed list takes. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { OpenCodeDriver } from '../driver'; +import { providersConfig, serverSetup } from '../providers'; +import type { StartOpenCodeServerOptions } from '../server'; +import type { ProviderBinding } from '../../../sdk/types'; + +const router = (over: Partial = {}): ProviderBinding => ({ + id: 'router', + label: 'Home router', + baseUrl: 'http://192.168.1.2:3458', + authToken: 'tok-secret', + models: [{ id: 'Z.ai/glm-5', label: 'GLM 5' }, { id: 'kimi-k3' }], + defaultModel: 'kimi-k3', + ...over, +}); + +describe('providersConfig', () => { + it("adds each profile as a provider of its own, beside the operator's, with the token left to the environment", () => { + const config = providersConfig([router()], { provider: { mine: { npm: 'x' } }, theme: 'dark' }); + expect(config).toEqual({ + theme: 'dark', + provider: { + mine: { npm: 'x' }, + 'codedeck-router': { + npm: '@ai-sdk/openai-compatible', + name: 'Home router', + options: { baseURL: 'http://192.168.1.2:3458/v1', apiKey: '{env:CODEDECK_PROVIDER_KEY_0}' }, + models: { 'Z.ai/glm-5': { name: 'GLM 5' }, 'kimi-k3': {} }, + }, + }, + }); + expect(JSON.stringify(config)).not.toContain('tok-secret'); + }); + + it('does not restrict the providers OpenCode already has', () => { + const config = providersConfig([router()]); + expect(config).not.toHaveProperty('enabled_providers'); + expect(config).not.toHaveProperty('disabled_providers'); + expect(config).not.toHaveProperty('model'); + }); +}); + +describe('serverSetup', () => { + it('passes the tokens in the environment and guards the server with a password of its own', () => { + const a = serverSetup([router(), router({ id: 'or', authToken: 'tok-2' })], {}); + expect(a.env.CODEDECK_PROVIDER_KEY_0).toBe('tok-secret'); + expect(a.env.CODEDECK_PROVIDER_KEY_1).toBe('tok-2'); + expect(JSON.parse(a.env.OPENCODE_CONFIG_CONTENT!).provider).toHaveProperty('codedeck-or'); + const password = a.env.OPENCODE_SERVER_PASSWORD!; + expect(password).toMatch(/^[0-9a-f]{48}$/); + expect(a.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); + expect(serverSetup([], {}).env.OPENCODE_SERVER_PASSWORD).not.toBe(password); + // No profiles: the operator's config stands as it is. + expect(serverSetup([], { OPENCODE_CONFIG_CONTENT: '{"theme":"x"}' }).env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + }); + + it("keeps the operator's own environment config underneath", () => { + const { env } = serverSetup([router()], { OPENCODE_CONFIG_CONTENT: '{"provider":{"mine":{"npm":"x"}}}' }); + expect(Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT!).provider)).toEqual(['mine', 'codedeck-router']); + }); +}); + +describe('an OpenCode driver given provider profiles', () => { + function managed() { + const starts: StartOpenCodeServerOptions[] = []; + const closed: number[] = []; + const connects: Array<{ baseUrl: string; headers?: Record }> = []; + const driver = OpenCodeDriver.create({ + autoStart: true, + // Any file stands in for the executable: the start is faked. + binaryPath: process.execPath, + log: () => {}, + startServer: async (options) => { + starts.push(options); + const n = starts.length; + return { url: `http://127.0.0.1:${4100 + n}`, pid: n, exited: new Promise(() => {}), close: async () => void closed.push(n) }; + }, + connect: (config) => { + connects.push(config); + return {} as OpencodeClient; + }, + }); + return { driver, starts, closed, connects }; + } + + it('adds them to the server it starts, and restarts it only when they change', async () => { + const { driver: created, starts, closed, connects } = managed(); + const driver = await created; + expect(driver.info().supports?.providerModels).toBe(true); + expect(driver.info().supports?.providers).toBe(false); + expect(starts).toHaveLength(1); + expect(starts[0]!.env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + expect(connects[0]!.headers?.authorization).toMatch(/^Basic /); + + await driver.setProviders([router()]); + expect(starts).toHaveLength(2); + expect(closed).toEqual([1]); + expect(starts[1]!.env?.CODEDECK_PROVIDER_KEY_0).toBe('tok-secret'); + + await driver.setProviders([router()]); + expect(starts).toHaveLength(2); + + // One the bridge would not let a session use is left out. + await driver.setProviders([router(), router({ id: 'bad', baseUrl: 'http://8.8.8.8' })]); + expect(starts).toHaveLength(2); + + await driver.setProviders([]); + expect(starts).toHaveLength(3); + expect(starts[2]!.env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + await driver.shutdown(); + expect(closed).toEqual([1, 2, 3]); + }); + + it('refuses them for a server it does not start', async () => { + const driver = await OpenCodeDriver.create({ serverUrl: 'http://127.0.0.1:4096', log: () => {}, connect: () => ({}) as OpencodeClient }); + expect(driver.info().supports?.providerModels).toBe(false); + await expect(driver.setProviders([router()])).rejects.toThrow(/does not start/); + }); + + it('checks and lists a profile as an OpenAI-compatible endpoint', async () => { + const post = vi.fn().mockResolvedValue({ status: 401 }); + const get = vi.fn().mockResolvedValue({ status: 200, text: JSON.stringify({ data: [{ id: 'kimi-k3' }] }) }); + const driver = await OpenCodeDriver.create({ serverUrl: 'http://x', log: () => {}, providerHttp: { get, post }, connect: () => ({}) as OpencodeClient }); + expect(await driver.checkProvider(router(), 'kimi-k3')).toBe(false); + const [url, headers, body] = post.mock.calls[0]!; + expect(url).toBe('http://192.168.1.2:3458/v1/chat/completions'); + expect(headers).toEqual({ 'content-type': 'application/json', authorization: 'Bearer tok-secret' }); + expect(JSON.parse(body)).toMatchObject({ model: 'kimi-k3', max_tokens: 1 }); + // Its models are read the same way: the OpenAI sign-in only. + expect(await driver.listProviderModels('http://192.168.1.2:3458', 'tok-secret')).toEqual([{ id: 'kimi-k3' }]); + expect(get).toHaveBeenCalledWith('http://192.168.1.2:3458/v1/models', { authorization: 'Bearer tok-secret' }); + }); +}); diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 1a5b0119..bc8c3a7b 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -15,8 +15,10 @@ * only for models whose provider declares a context limit; * - no effort levels; models are per prompt (`provider/model` ids). * - * A session bound to a provider profile runs on an OpenCode server of its - * profile's own (profiles.ts); its model ids are the profile's. + * Provider profiles are added to the server this driver starts as + * providers of their own (providers.ts): their models join the list beside + * OpenCode Zen and the operator's providers, and a session picks one like + * any other. */ import { createOpencodeClient } from '@opencode-ai/sdk/v2/client'; import type { @@ -51,9 +53,17 @@ import type { import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; -import { PROFILE_PROVIDER_ID, ProfileServers } from './profiles'; -import { resolveOpenCodePath, startOpenCodeServer, type OpenCodeServerHandle } from './server'; -import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../sdk/provider'; +import { providersFingerprint, serverSetup } from './providers'; +import { + resolveOpenCodePath, + startOpenCodeServer, + type OpenCodeServerHandle, + type StartOpenCodeServerOptions, +} from './server'; +import type { ProviderHttp } from '../../sdk/net'; +import { isValidProviderBaseUrl } from '../../sdk/provider'; +import { checkToken, listModels, OPENAI_API } from '../../sdk/providerApi'; +import type { EndpointModel } from '../../sdk/providerModels'; export const OPENCODE_AGENT_ID = 'opencode'; @@ -326,8 +336,6 @@ export class OpenCodeSession implements DriverSession { /** Resolves once the OpenCode session exists server-side. */ private readonly ready: Promise<{ client: OpencodeClient; session: Session }>; private readonly catalog: () => Promise; - private readonly toPromptModel: (id: string) => { providerID: string; modelID: string } | undefined; - private readonly onClose?: () => void; constructor( params: StartSession, @@ -338,9 +346,7 @@ export class OpenCodeSession implements DriverSession { this.cwd = params.cwd; this.mode = params.mode ?? DEFAULT_MODE; this.catalog = options.catalog ?? (async () => ({ models: [] })); - this.toPromptModel = options.toPromptModel ?? splitModelId; - this.onClose = options.onClose; - this.model = params.model ? this.toPromptModel(params.model) : undefined; + this.model = splitModelId(params.model ?? undefined); this.ready = this.init(clientPromise, params); // init() reports its own failure as `ended`; nothing else awaits this // rejection except prompt/interrupt, which catch it themselves. @@ -358,7 +364,6 @@ export class OpenCodeSession implements DriverSession { this.ended = true; this.abortController.abort(); this.ctx.emit({ type: 'ended', ...(error ? { error } : {}) }); - this.onClose?.(); } private async init( @@ -378,7 +383,7 @@ export class OpenCodeSession implements DriverSession { if (refused) throw new Error(refused); } const model = params.model ?? catalog.defaultModel; - if (!params.model && model) this.model = this.toPromptModel(model); + if (!params.model) this.model = splitModelId(model); // Subscribe BEFORE resolving/creating the session so no event in the gap // between "session exists" and "we started listening" is missed. The // stream is directory-scoped (a server can host multiple projects); this @@ -889,7 +894,7 @@ export class OpenCodeSession implements DriverSession { return; case 'model': { // Model selection is per prompt in OpenCode; the next prompt uses it. - const split = this.toPromptModel(value); + const split = splitModelId(value); if (!split) throw new Error(`'${value}' is not an OpenCode provider/model id`); const refused = unsupportedModelReason(value, (await this.catalog()).models); if (refused) throw new Error(refused); @@ -936,8 +941,6 @@ export class OpenCodeSession implements DriverSession { } catch { // Best effort — the session may never have been created, or may // already be gone server-side. - } finally { - this.onClose?.(); } } } @@ -945,29 +948,6 @@ export class OpenCodeSession implements DriverSession { export interface OpenCodeSessionOptions { /** The models a session may choose from, and its default. */ catalog?: () => Promise; - /** How a model id the phone sends is named in a prompt; `provider/model` - * ids split at their first `/` by default. */ - toPromptModel?: (id: string) => { providerID: string; modelID: string } | undefined; - /** Called once when the session is over, however it ended. */ - onClose?: () => void; -} - -/** Why a provider profile cannot run a session, if it cannot. */ -function profileRefusal(provider: ProviderBinding, model: string | null | undefined, resume: boolean): string | undefined { - // Checked before the token: this decides whether the token may travel on - // this connection at all. The base URL is not a secret. - if (!isValidProviderBaseUrl(provider.baseUrl)) { - return ( - `provider profile '${provider.id}' has an insecure base URL (${provider.baseUrl}) — ` + - `${PROVIDER_BASE_URL_ERROR}. Its API token would travel in cleartext, so the session is refused.` - ); - } - if (!provider.authToken) return `provider profile '${provider.id}' has no stored auth token`; - if (provider.models.length === 0) return `provider profile '${provider.id}' lists no models`; - if (model && !resume && !provider.models.some((m) => m.id === model)) { - return `The provider profile '${provider.id}' does not offer the model '${model}' — choose one of its models.`; - } - return undefined; } export interface OpenCodeDriverOptions { @@ -984,9 +964,11 @@ export interface OpenCodeDriverOptions { lookupEnv?: NodeJS.ProcessEnv; port?: number; log: (message: string) => void; - /** The servers of provider-profile sessions; built from the options above - * when absent. */ - profileServers?: ProfileServers; + /** Outbound HTTP to provider profiles' endpoints. */ + providerHttp?: ProviderHttp; + /** Seams for tests: how the server is started and connected to. */ + startServer?: (options: StartOpenCodeServerOptions) => Promise; + connect?: (config: { baseUrl: string; headers?: Record }) => OpencodeClient; } /** Why OpenCode cannot run, when it is enabled but unusable. */ @@ -997,39 +979,32 @@ export class OpenCodeDriver implements Driver { private clientPromise: Promise | null = null; private server: OpenCodeServerHandle | null = null; private unavailable: string | undefined; + /** The `opencode` the server runs, once found or installed. */ + private bin: string | null = null; /** Auto-start with no `opencode` on the machine: it is installed, then * started, in the background; a failed attempt is retried by the next * session. */ private installs = false; private stopped = false; + /** The provider profiles the server should have, and those the running + * one was started with. */ + private providers: ProviderBinding[] = []; + private served = providersFingerprint([]); + /** Provider changes are applied one at a time, in order. */ + private applying: Promise = Promise.resolve(); readonly plugins: PluginManager = new OpenCodePlugins(() => this.client()); readonly mcp: McpManager = new OpenCodeMcp(() => this.client()); - private readonly profiles: ProfileServers; - - private constructor(private readonly options: OpenCodeDriverOptions) { - this.profiles = - options.profileServers ?? - new ProfileServers({ - start: async (env) => { - const server = await startOpenCodeServer({ command: await this.executable(), env: { ...process.env, ...env } }); - return { url: server.url, exited: server.exited, close: () => server.close() }; - }, - log: options.log, - }); - } - /** The `opencode` a profile's server runs: the one on the machine, else - * the pinned one installed on demand. */ - private async executable(): Promise { - const found = resolveOpenCodePath(this.options.binaryPath, this.options.lookupEnv); - if (found) return found; - if (this.options.installOpenCode) return this.options.installOpenCode(); - throw new Error('A provider profile runs on an OpenCode server of its own, and no `opencode` executable was found (set CODEDECK_OPENCODE_PATH).'); + private constructor(private readonly options: OpenCodeDriverOptions) {} + + /** The driver starts the server, so it decides the server's config. */ + private get manages(): boolean { + return !this.options.serverUrl && Boolean(this.options.autoStart); } - /** The server's client, starting an install that failed before over. */ + /** The server's client, starting it again when the last attempt failed. */ private client(): Promise { - if (!this.clientPromise && this.installs && !this.stopped) this.launchInstalled(); + if (!this.clientPromise && !this.stopped && (this.bin || this.installs)) this.launch(); return this.clientPromise ?? Promise.reject(new Error(this.unavailable ?? NOT_CONFIGURED)); } @@ -1039,22 +1014,20 @@ export class OpenCodeDriver implements Driver { const driver = new OpenCodeDriver(options); if (options.serverUrl) { if (options.autoStart) options.log('[opencode] both a server URL and auto-start are configured — using the server URL'); - driver.connect(options.serverUrl); + driver.clientPromise = Promise.resolve(driver.connect({ baseUrl: options.serverUrl })); } else if (options.autoStart) { - const bin = resolveOpenCodePath(options.binaryPath, options.lookupEnv); - if (!bin && options.installOpenCode) { + driver.bin = resolveOpenCodePath(options.binaryPath, options.lookupEnv); + if (!driver.bin && options.installOpenCode) { driver.installs = true; - driver.launchInstalled(); - } else if (!bin) { + driver.launch(); + } else if (!driver.bin) { driver.unavailable = 'OpenCode auto-start is enabled but the `opencode` executable was not found (set CODEDECK_OPENCODE_PATH).'; } else { try { - driver.server = await startOpenCodeServer({ command: bin, ...(options.port !== undefined ? { port: options.port } : {}) }); - options.log(`[opencode] started ${driver.server.url} (pid ${driver.server.pid ?? '?'})`); - driver.connect(driver.server.url); + driver.clientPromise = Promise.resolve(await driver.startServer(driver.bin)); } catch (err) { - driver.unavailable = `The OpenCode server failed to start: ${err instanceof Error ? err.message : String(err)}`; + driver.unavailable = err instanceof Error ? err.message : String(err); } } } else { @@ -1065,41 +1038,89 @@ export class OpenCodeDriver implements Driver { } /** A driver over an existing client (tests). */ - static withClient(client: OpencodeClient, profileServers?: ProfileServers): OpenCodeDriver { - const driver = new OpenCodeDriver({ log: () => {}, ...(profileServers ? { profileServers } : {}) }); + static withClient(client: OpencodeClient): OpenCodeDriver { + const driver = new OpenCodeDriver({ log: () => {} }); driver.clientPromise = Promise.resolve(client); return driver; } - private connect(baseUrl: string): void { - this.clientPromise = Promise.resolve(createOpencodeClient({ baseUrl })); + private connect(config: { baseUrl: string; headers?: Record }): OpencodeClient { + return (this.options.connect ?? createOpencodeClient)(config); } - /** Install `opencode`, start its server and connect — sessions started - * meanwhile wait on the same promise. */ - private launchInstalled(): void { - const { installOpenCode, port, log } = this.options; + /** Start `opencode serve` with the provider profiles it should have, and + * connect to it. */ + private async startServer(bin: string): Promise { + const profiles = this.providers; + const setup = serverSetup(profiles, process.env); + const start = this.options.startServer ?? startOpenCodeServer; + let server: OpenCodeServerHandle; + try { + server = await start({ + command: bin, + env: { ...process.env, ...setup.env }, + ...(this.options.port !== undefined ? { port: this.options.port } : {}), + }); + } catch (err) { + throw new Error(`The OpenCode server failed to start: ${err instanceof Error ? err.message : String(err)}`); + } + if (this.stopped) { + await server.close(); + throw new Error('the agent host is shutting down'); + } + this.server = server; + this.served = providersFingerprint(profiles); + const added = profiles.length > 0 ? `, with ${profiles.length} provider profile(s)` : ''; + this.options.log(`[opencode] started ${server.url} (pid ${server.pid ?? '?'})${added}`); + return this.connect({ baseUrl: server.url, headers: setup.headers }); + } + + /** Find (or install) `opencode`, start its server and connect — sessions + * started meanwhile wait on the same promise. */ + private launch(): void { const attempt = (async (): Promise => { - const bin = await installOpenCode!(); - let server: OpenCodeServerHandle; - try { - server = await startOpenCodeServer({ command: bin, ...(port !== undefined ? { port } : {}) }); - } catch (err) { - throw new Error(`The OpenCode server failed to start: ${err instanceof Error ? err.message : String(err)}`); - } - if (this.stopped) { - await server.close(); - throw new Error('the agent host is shutting down'); - } - this.server = server; - log(`[opencode] started ${server.url} (pid ${server.pid ?? '?'})`); - return createOpencodeClient({ baseUrl: server.url }); + this.bin ??= await this.options.installOpenCode!(); + return this.startServer(this.bin); })(); this.clientPromise = attempt; - attempt.catch((err: unknown) => { - log(`[opencode] unavailable: ${err instanceof Error ? err.message : String(err)}`); - if (this.clientPromise === attempt) this.clientPromise = null; - }); + attempt.then( + () => { + this.unavailable = undefined; + }, + (err: unknown) => { + this.options.log(`[opencode] unavailable: ${err instanceof Error ? err.message : String(err)}`); + if (this.clientPromise === attempt) this.clientPromise = null; + }, + ); + } + + /** The provider profiles to add to OpenCode's own providers. The server + * reads its config only when it starts, so a changed list restarts it: + * sessions on the old one end with an error and the bridge resumes them + * on the new one. */ + async setProviders(providers: ProviderBinding[]): Promise { + if (!this.manages) { + throw new Error('OpenCode runs on a server this bridge does not start (CODEDECK_OPENCODE_SERVER_URL), so it cannot add provider profiles to it'); + } + // A profile the bridge would not let a session use is not added either. + this.providers = providers.filter((p) => isValidProviderBaseUrl(p.baseUrl) && p.authToken !== '' && p.models.length > 0); + const apply = this.applying.then(() => this.applyProviders()); + this.applying = apply.catch(() => {}); + await apply; + } + + private async applyProviders(): Promise { + // A server still being installed or started is waited for: it may have + // read an older list. + await this.clientPromise?.catch(() => {}); + if (this.stopped || !this.server || !this.bin || providersFingerprint(this.providers) === this.served) return; + this.options.log('[opencode] restarting the server: its provider profiles changed'); + const old = this.server; + this.server = null; + this.clientPromise = null; + await old.close(); + this.launch(); + await this.clientPromise; } info(): AgentInfo { @@ -1109,41 +1130,47 @@ export class OpenCodeDriver implements Driver { modes: OPENCODE_MODES, efforts: [], defaultMode: DEFAULT_MODE, - // No subscription usage. A provider profile is reached as an - // OpenAI-compatible endpoint. - supports: { models: true, usage: false, providers: true, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true }, + // No subscription usage. Provider profiles add models to the server + // this driver starts; sessions are never bound to one. + supports: { + models: true, + usage: false, + providers: false, + providerModels: this.manages, + gsd: true, + interrupt: true, + commands: true, + plugins: true, + mcp: true, + tasks: true, + }, credentials: [], ...(this.unavailable ? { unavailableReason: this.unavailable } : {}), }; } startSession(params: StartSession, ctx: SessionContext): DriverSession { + if (params.provider) throw new Error('OpenCode offers provider profiles as models; a session is not bound to one'); + if (!this.clientPromise && this.installs && !this.stopped) this.launch(); + if (!this.clientPromise) throw new Error(this.unavailable ?? NOT_CONFIGURED); if (params.mode !== undefined && !OPENCODE_MODES.some((m) => m.id === params.mode)) { throw new Error(`OpenCode has no mode '${params.mode}'`); } - if (params.provider) return this.startProfileSession(params, params.provider, ctx); - if (!this.clientPromise && this.installs && !this.stopped) this.launchInstalled(); - if (!this.clientPromise) throw new Error(this.unavailable ?? NOT_CONFIGURED); if (params.model && !splitModelId(params.model)) { throw new Error(`'${params.model}' is not an OpenCode provider/model id — choose one from its model list.`); } return new OpenCodeSession(params, ctx, this.clientPromise, { catalog: () => this.listModels() }); } - /** A session on its provider profile's own server, naming the profile's - * models as they are (no provider prefix). Refused loudly when the - * profile cannot run it — never a fallback to the machine's providers. */ - private startProfileSession(params: StartSession, provider: ProviderBinding, ctx: SessionContext): DriverSession { - const refused = profileRefusal(provider, params.model, Boolean(params.resume)); - if (refused) throw new Error(refused); - const lease = this.profiles.acquire(provider); - const models: ModelEntry[] = provider.models.map((m) => ({ id: m.id, ...(m.label ? { label: m.label } : {}) })); - const defaultModel = provider.defaultModel ?? provider.models[0]?.id; - return new OpenCodeSession(params, ctx, lease.client, { - catalog: async () => ({ models, ...(defaultModel ? { defaultModel } : {}) }), - toPromptModel: (id) => ({ providerID: PROFILE_PROVIDER_ID, modelID: id }), - onClose: () => lease.release(), - }); + /** A profile is reached as an OpenAI-compatible provider: a session on + * it posts chat completions. */ + async checkProvider(provider: ProviderBinding, model: string): Promise { + return this.options.providerHttp ? checkToken(OPENAI_API, provider, model, this.options.providerHttp.post) : undefined; + } + + async listProviderModels(baseUrl: string, token: string): Promise { + if (!this.options.providerHttp) throw new Error('this host reads no provider endpoints'); + return listModels(OPENAI_API, baseUrl, token, this.options.providerHttp.get); } /** Best-effort model list from OpenCode's configured providers, and the @@ -1184,6 +1211,6 @@ export class OpenCodeDriver implements Driver { async shutdown(): Promise { this.stopped = true; - await Promise.all([this.server?.close(), this.profiles.closeAll()]); + await this.server?.close(); } } diff --git a/packages/agent-host/src/drivers/opencode/module.ts b/packages/agent-host/src/drivers/opencode/module.ts index a0b332ae..694b48fb 100644 --- a/packages/agent-host/src/drivers/opencode/module.ts +++ b/packages/agent-host/src/drivers/opencode/module.ts @@ -7,6 +7,7 @@ */ import { installBinary } from '../../install/agentInstall'; import type { DriverEnv, DriverModule } from '../../sdk/module'; +import { providerHttp } from '../../sdk/net'; import { OpenCodeDriver } from './driver'; import { openCodeBinary } from './install'; import { resolveOpenCodePath } from './server'; @@ -29,6 +30,7 @@ export const openCodeModule: DriverModule = { lookupEnv: ctx.lookupEnv, ...(port !== undefined && Number.isInteger(port) ? { port } : {}), log: ctx.log, + providerHttp, }); }, runtime: { diff --git a/packages/agent-host/src/drivers/opencode/profiles.ts b/packages/agent-host/src/drivers/opencode/profiles.ts deleted file mode 100644 index 73bc0d14..00000000 --- a/packages/agent-host/src/drivers/opencode/profiles.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * OpenCode servers for sessions bound to a provider profile. - * - * A profile is an OpenAI-compatible endpoint, its token and its models; - * OpenCode reaches one as a custom provider in its config. The machine's - * own server cannot take it: its config is the operator's file, shared by - * every session, and anything written there (the token included) would stay - * on disk and reach every other session. So each profile in use gets an - * OpenCode server of its own, whose config is handed over in the - * environment when it starts — the operator's config files still load - * underneath (MCP servers, plugins, agents), with the profile on top: - * - * - the profile's endpoint is the only provider enabled, and it is the - * model and small model, so nothing in the session (titles and subagents - * included) falls back to the operator's own accounts; - * - the token is in the server's environment only, referenced from the - * config, never in a file; - * - the server answers only with a password made for it, since its API - * reads its config — the token included — back to anyone on loopback - * who asks. - * - * A server lives while sessions run on it and a little after, so a restart - * or the next session reuses it; a changed profile (a new token, other - * models) gets a new server, and the old one goes once its sessions end. - */ -import { createHash, randomBytes } from 'node:crypto'; -import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk/v2/client'; -import { providerApiRoot } from '../../sdk/providerModels'; -import type { ProviderBinding } from '../../sdk/types'; - -/** The provider id a profile has inside its server; a session's model ids - * are the profile's own, and its prompts name them under this. */ -export const PROFILE_PROVIDER_ID = 'codedeck'; -/** The variable the profile's token reaches its server in. */ -export const PROFILE_TOKEN_ENV = 'CODEDECK_PROVIDER_API_KEY'; -/** The user name OpenCode's server expects with its password. */ -const SERVER_USERNAME = 'opencode'; -/** How long a server with no session on it is kept for the next one. */ -export const PROFILE_SERVER_IDLE_MS = 5 * 60_000; - -/** A started server: where it listens, when it exits, how to stop it. */ -export interface StartedServer { - url: string; - exited: Promise; - close(): Promise; -} - -/** Starts `opencode serve` with `env` added to the host's environment. */ -export type StartServer = (env: Record) => Promise; - -/** A client of one profile's server, held until `release`. */ -export interface ProfileLease { - client: Promise; - /** Idempotent. */ - release(): void; -} - -/** The config a profile's server starts with, on top of the operator's. */ -export function profileConfig(provider: ProviderBinding): Record { - const fallback = provider.defaultModel ?? provider.models[0]?.id; - return { - enabled_providers: [PROFILE_PROVIDER_ID], - ...(fallback ? { model: `${PROFILE_PROVIDER_ID}/${fallback}`, small_model: `${PROFILE_PROVIDER_ID}/${fallback}` } : {}), - provider: { - [PROFILE_PROVIDER_ID]: { - npm: '@ai-sdk/openai-compatible', - name: provider.id, - options: { baseURL: providerApiRoot(provider.baseUrl), apiKey: `{env:${PROFILE_TOKEN_ENV}}` }, - models: Object.fromEntries(provider.models.map((m) => [m.id, m.label ? { name: m.label } : {}])), - }, - }, - }; -} - -interface Entry { - client: Promise; - server: Promise; - leases: number; - idle?: ReturnType; -} - -export interface ProfileServersOptions { - start: StartServer; - log: (message: string) => void; - idleMs?: number; - /** Builds the client of a started server (a seam for tests). */ - connect?: (url: string, headers: Record) => OpencodeClient; -} - -export class ProfileServers { - private readonly servers = new Map(); - private closed = false; - - constructor(private readonly options: ProfileServersOptions) {} - - /** A client of the server for `provider`, starting one when none runs. */ - acquire(provider: ProviderBinding): ProfileLease { - if (this.closed) return { client: Promise.reject(new Error('the agent host is shutting down')), release: () => {} }; - const key = fingerprint(provider); - let entry = this.servers.get(key); - if (!entry) entry = this.launch(key, provider); - clearTimeout(entry.idle); - entry.idle = undefined; - entry.leases += 1; - const held = entry; - let released = false; - return { - client: held.client, - release: () => { - if (released) return; - released = true; - held.leases -= 1; - if (held.leases === 0 && this.servers.get(key) === held) { - held.idle = setTimeout(() => this.retire(key, held), this.options.idleMs ?? PROFILE_SERVER_IDLE_MS); - held.idle.unref?.(); - } - }, - }; - } - - /** Stop every server. */ - async closeAll(): Promise { - this.closed = true; - const entries = [...this.servers.values()]; - this.servers.clear(); - await Promise.all( - entries.map(async (entry) => { - clearTimeout(entry.idle); - await entry.server.then((s) => s.close(), () => {}); - }), - ); - } - - private launch(key: string, provider: ProviderBinding): Entry { - const password = randomBytes(24).toString('hex'); - const server = this.options.start({ - OPENCODE_CONFIG_CONTENT: JSON.stringify(profileConfig(provider)), - [PROFILE_TOKEN_ENV]: provider.authToken, - OPENCODE_SERVER_USERNAME: SERVER_USERNAME, - OPENCODE_SERVER_PASSWORD: password, - }); - const headers = { authorization: `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString('base64')}` }; - const connect = this.options.connect ?? ((url, h) => createOpencodeClient({ baseUrl: url, headers: h })); - const entry: Entry = { server, client: server.then((s) => connect(s.url, headers)), leases: 0 }; - this.servers.set(key, entry); - server.then( - (s) => { - this.options.log(`[opencode] started ${s.url} for provider profile '${provider.id}'`); - // A server that died is forgotten, so the next session starts another. - void s.exited.then(() => { - if (this.servers.get(key) === entry) this.servers.delete(key); - }); - }, - (error: unknown) => { - this.options.log( - `[opencode] the server for provider profile '${provider.id}' failed to start: ${error instanceof Error ? error.message : String(error)}`, - ); - if (this.servers.get(key) === entry) this.servers.delete(key); - }, - ); - return entry; - } - - private retire(key: string, entry: Entry): void { - if (this.servers.get(key) !== entry || entry.leases > 0) return; - this.servers.delete(key); - void entry.server.then((s) => s.close(), () => {}); - } -} - -/** What makes two profiles need different servers: everything their - * config is built from. Hashed, so the token is not kept as a map key. */ -function fingerprint(provider: ProviderBinding): string { - const parts = [provider.id, provider.baseUrl, provider.authToken, provider.defaultModel ?? '', provider.models]; - return createHash('sha256').update(JSON.stringify(parts)).digest('hex'); -} diff --git a/packages/agent-host/src/drivers/opencode/providers.ts b/packages/agent-host/src/drivers/opencode/providers.ts new file mode 100644 index 00000000..e970a2a7 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/providers.ts @@ -0,0 +1,103 @@ +/** + * Provider profiles as OpenCode providers. + * + * A profile adds a provider to OpenCode — beside OpenCode Zen and whatever + * the operator configured, which all stay: an OpenAI-compatible endpoint, + * its token and its models. OpenCode takes a custom provider only from its + * config, so the profiles reach the server this driver starts as config + * handed over in its environment, on top of the operator's files (which are + * never written): + * + * - each profile is the provider `codedeck-`, named after the + * profile, so its models are `codedeck-/` and the phone shows + * them under the profile's name; + * - each token sits in the server's environment only, referenced from the + * config, never in a file; + * - the server answers only with a password made for it, since its API + * reads its config — the tokens included — back to anyone on loopback + * who asks. + * + * The server reads its config when it starts, so a changed profile list + * means a new server (see the driver). + */ +import { createHash, randomBytes } from 'node:crypto'; +import { providerApiRoot } from '../../sdk/providerModels'; +import type { ProviderBinding } from '../../sdk/types'; + +/** What a profile's provider id starts with: no OpenCode provider (models.dev + * catalog or the operator's) is named so. */ +export const PROFILE_PROVIDER_PREFIX = 'codedeck-'; +/** The user name OpenCode's server expects with its password. */ +const SERVER_USERNAME = 'opencode'; + +/** The OpenCode provider id of a profile. */ +export function profileProviderId(profile: ProviderBinding): string { + return `${PROFILE_PROVIDER_PREFIX}${profile.id}`; +} + +/** The variable profile `index`'s token reaches the server in. */ +function tokenVariable(index: number): string { + return `CODEDECK_PROVIDER_KEY_${index}`; +} + +/** The provider entries for `profiles`, on top of `base` (an operator's own + * `OPENCODE_CONFIG_CONTENT`, whose providers stay). */ +export function providersConfig(profiles: ProviderBinding[], base: Record = {}): Record { + const own = (base.provider ?? {}) as Record; + const added = Object.fromEntries( + profiles.map((profile, index) => [ + profileProviderId(profile), + { + npm: '@ai-sdk/openai-compatible', + name: profile.label || profile.id, + options: { baseURL: providerApiRoot(profile.baseUrl), apiKey: `{env:${tokenVariable(index)}}` }, + models: Object.fromEntries(profile.models.map((m) => [m.id, m.label ? { name: m.label } : {}])), + }, + ]), + ); + return { ...base, provider: { ...own, ...added } }; +} + +/** What the server is started with to serve `profiles`, and how its client + * signs in. */ +export interface ServerSetup { + env: Record; + headers: Record; +} + +/** The environment and credentials of a server for `profiles`. `baseEnv` + * is the host's own environment. */ +export function serverSetup(profiles: ProviderBinding[], baseEnv: NodeJS.ProcessEnv): ServerSetup { + const password = randomBytes(24).toString('hex'); + const env: Record = { + OPENCODE_SERVER_USERNAME: SERVER_USERNAME, + OPENCODE_SERVER_PASSWORD: password, + }; + if (profiles.length > 0) { + env.OPENCODE_CONFIG_CONTENT = JSON.stringify(providersConfig(profiles, operatorConfig(baseEnv))); + profiles.forEach((profile, index) => { + env[tokenVariable(index)] = profile.authToken; + }); + } + const headers = { authorization: `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString('base64')}` }; + return { env, headers }; +} + +/** The config the operator handed OpenCode in the environment, if any. */ +function operatorConfig(baseEnv: NodeJS.ProcessEnv): Record { + const raw = baseEnv.OPENCODE_CONFIG_CONTENT; + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** What makes two profile lists need different servers: everything their + * config is built from. Hashed, so the tokens are not kept around in it. */ +export function providersFingerprint(profiles: ProviderBinding[]): string { + const parts = profiles.map((p) => [p.id, p.label, p.baseUrl, p.authToken, p.models]); + return createHash('sha256').update(JSON.stringify(parts)).digest('hex'); +} diff --git a/packages/agent-host/src/generated/protocol.ts b/packages/agent-host/src/generated/protocol.ts index ea23c0e1..c4d2f054 100644 --- a/packages/agent-host/src/generated/protocol.ts +++ b/packages/agent-host/src/generated/protocol.ts @@ -59,8 +59,17 @@ export type AgentSupports = { models?: boolean, /** `usage-request` returns subscription usage for this agent's sessions. */ usage?: boolean, - /** Sessions may be bound to a custom provider profile (`providerId`). */ + /** + * Sessions may be bound to one of this agent's provider profiles + * (`providerId`), which then serves the whole session. + */ providers?: boolean, + /** + * This agent's provider profiles add their models to its own model + * list, beside every provider it already has; a session picks one of + * them as it would any other model. + */ + providerModels?: boolean, /** `gsd-request` returns GSD workflow state for this agent's sessions. */ gsd?: boolean, /** `interrupt` stops the running turn. */ @@ -241,6 +250,36 @@ export type BridgeMessage_Deserialize = credential: string, value: Secret, } } | +/** + * Check a provider profile's token with the endpoint, the way `agent` + * would use it, on `model`. Reply: `credential-checked`. + */ +{ kind: "check-provider"; payload: { + agent: string, + provider: ProviderBinding_Deserialize, + model: string, +} } | +/** + * The models the endpoint at `base_url` lists, read with `auth_token` + * the way `agent` speaks to it (a provider profile of `agent` being + * saved). Reply: `provider-models`, or `error` saying why there is no + * list. + */ +{ kind: "list-provider-models"; payload: { + agent: string, + baseUrl: string, + authToken: Secret, +} } | +/** + * The provider profiles of an agent whose catalog entry `supports` + * `providerModels`, all of them: sent after `initialize` and whenever + * one changes. The agent offers their models beside its own. Reply: + * `ack`. + */ +{ kind: "set-providers"; payload: { + agent: string, + providers: ProviderBinding_Deserialize[], +} } | /** Reply to `request-permission`. */ { kind: "permission-outcome"; payload: SelectOutcome } | /** Reply to `request-plan-approval`. */ @@ -373,6 +412,36 @@ export type BridgeMessage_Serialize = credential: string, value: Secret, } } | +/** + * Check a provider profile's token with the endpoint, the way `agent` + * would use it, on `model`. Reply: `credential-checked`. + */ +{ kind: "check-provider"; payload: { + agent: string, + provider: ProviderBinding_Serialize, + model: string, +} } | +/** + * The models the endpoint at `base_url` lists, read with `auth_token` + * the way `agent` speaks to it (a provider profile of `agent` being + * saved). Reply: `provider-models`, or `error` saying why there is no + * list. + */ +{ kind: "list-provider-models"; payload: { + agent: string, + baseUrl: string, + authToken: Secret, +} } | +/** + * The provider profiles of an agent whose catalog entry `supports` + * `providerModels`, all of them: sent after `initialize` and whenever + * one changes. The agent offers their models beside its own. Reply: + * `ack`. + */ +{ kind: "set-providers"; payload: { + agent: string, + providers: ProviderBinding_Serialize[], +} } | /** Reply to `request-permission`. */ { kind: "permission-outcome"; payload: SelectOutcome } | /** Reply to `request-plan-approval`. */ @@ -683,10 +752,17 @@ export type HostMessage_Deserialize = toggles?: boolean, projectWide?: boolean, } } | -/** Reply to `check-credential`; absent `valid` = it could not be checked. */ +/** + * Reply to `check-credential` and `check-provider`; absent `valid` = it + * could not be checked. + */ { kind: "credential-checked"; payload: { valid?: boolean | null, } } | +/** Reply to `list-provider-models`: never empty. */ +{ kind: "provider-models"; payload: { + models: ProviderModel_Deserialize[], +} } | /** A notification: no frame id, no reply. */ { kind: "session-event"; payload: { sessionId: string, @@ -755,10 +831,17 @@ export type HostMessage_Serialize = toggles?: boolean, projectWide?: boolean, } } | -/** Reply to `check-credential`; absent `valid` = it could not be checked. */ +/** + * Reply to `check-credential` and `check-provider`; absent `valid` = it + * could not be checked. + */ { kind: "credential-checked"; payload: { valid?: boolean | null, } } | +/** Reply to `list-provider-models`: never empty. */ +{ kind: "provider-models"; payload: { + models: ProviderModel_Serialize[], +} } | /** A notification: no frame id, no reply. */ { kind: "session-event"; payload: { sessionId: string, @@ -1075,21 +1158,34 @@ export type PluginMarketplace = { source: string, }; -/** A custom provider profile a session is bound to for its whole life. */ +/** + * A provider profile, token included: one a session is bound to for its + * whole life, or one an agent offers the models of (`set-providers`). + */ export type ProviderBinding = ProviderBinding_Serialize | ProviderBinding_Deserialize; -/** A custom provider profile a session is bound to for its whole life. */ +/** + * A provider profile, token included: one a session is bound to for its + * whole life, or one an agent offers the models of (`set-providers`). + */ export type ProviderBinding_Deserialize = { id: string, + /** The name the user gave it, for showing where a model comes from. */ + label: string, baseUrl: string, authToken: Secret, models: ProviderModel_Deserialize[], defaultModel?: string | null, }; -/** A custom provider profile a session is bound to for its whole life. */ +/** + * A provider profile, token included: one a session is bound to for its + * whole life, or one an agent offers the models of (`set-providers`). + */ export type ProviderBinding_Serialize = { id: string, + /** The name the user gave it, for showing where a model comes from. */ + label: string, baseUrl: string, authToken: Secret, models: ProviderModel_Serialize[], diff --git a/packages/agent-host/src/host/host.ts b/packages/agent-host/src/host/host.ts index c469afc0..b359c0a5 100644 --- a/packages/agent-host/src/host/host.ts +++ b/packages/agent-host/src/host/host.ts @@ -228,6 +228,24 @@ export class AgentHost { const valid = await driver.checkCredential?.(message.payload.credential, message.payload.value); return { kind: 'credential-checked', payload: valid === undefined ? {} : { valid } }; } + case 'check-provider': { + const { agent, provider, model } = message.payload; + const valid = await this.driver(agent).checkProvider?.(provider, model); + return { kind: 'credential-checked', payload: valid === undefined ? {} : { valid } }; + } + case 'list-provider-models': { + const { agent, baseUrl, authToken } = message.payload; + const driver = this.driver(agent); + if (!driver.listProviderModels) throw new Error(`${driver.info().displayName} cannot read a provider's models`); + const models = await driver.listProviderModels(baseUrl, authToken); + return { kind: 'provider-models', payload: { models: models.map((m) => ({ id: m.id, ...(m.label ? { label: m.label } : {}) })) } }; + } + case 'set-providers': { + const driver = this.driver(message.payload.agent); + if (!driver.setProviders) throw new Error(`${driver.info().displayName} does not add provider profiles to its models`); + await driver.setProviders(message.payload.providers); + return ack(); + } default: throw new Error(`unsupported request ${(message as { kind: string }).kind}`); } diff --git a/packages/agent-host/src/sdk/__tests__/providerModels.test.ts b/packages/agent-host/src/sdk/__tests__/providerModels.test.ts index 668577a9..d295b140 100644 --- a/packages/agent-host/src/sdk/__tests__/providerModels.test.ts +++ b/packages/agent-host/src/sdk/__tests__/providerModels.test.ts @@ -133,8 +133,8 @@ describe('fetchProviderModels', () => { ), ).toBeUndefined(); expect(await fetchProviderModels('not a url', options(answering({ data: [{ id: 'a' }] })))).toBeUndefined(); - expect(logs.some((line) => /^\[t\] .*answered 401/.test(line))).toBe(true); - expect(logs.some((line) => /listed no models/.test(line))).toBe(true); + expect(logs.some((line) => /^\[t\] .*refused the token \(HTTP 401\)/.test(line))).toBe(true); + expect(logs.some((line) => /lists no models/.test(line))).toBe(true); expect(logs.some((line) => /ECONNREFUSED/.test(line))).toBe(true); expect(logs.some((line) => /not a URL/.test(line))).toBe(true); }); diff --git a/packages/agent-host/src/sdk/driver.ts b/packages/agent-host/src/sdk/driver.ts index 55c38b0d..38b2396a 100644 --- a/packages/agent-host/src/sdk/driver.ts +++ b/packages/agent-host/src/sdk/driver.ts @@ -24,6 +24,7 @@ import type { PlanOutcome, PluginAction, PluginMarketplace, + ProviderBinding, QuestionOutcome, QuestionSpec, SelectOutcome, @@ -34,6 +35,7 @@ import type { StartSession, UsageData, } from './types'; +import type { EndpointModel } from './providerModels'; /** What a running session can do toward the bridge. */ export interface SessionContext { @@ -140,6 +142,19 @@ export interface Driver { /** Check a credential value with its provider: true/false, or undefined * when it could not be checked. */ checkCredential?(credential: string, value: string): Promise; + /** Check one of this agent's provider profiles: its token, with the + * smallest request on the API the agent speaks to it, on `model` + * (`sdk/providerCheck`). True/false, or undefined when it could not be + * checked. */ + checkProvider?(provider: ProviderBinding, model: string): Promise; + /** The models a provider profile's endpoint lists, read the way this + * agent's API signs in (`sdk/providerApi`). Rejects with the reason + * there is no list. */ + listProviderModels?(baseUrl: string, token: string): Promise; + /** For an agent whose catalog entry `supports.providerModels`: its + * provider profiles, all of them, whenever one changes. Their models are + * offered beside the agent's own; nothing the agent already has goes. */ + setProviders?(providers: ProviderBinding[]): Promise; /** * Delete the agent's own record of a conversation (an `info` * `nativeSessionId` it reported, run in `cwd`): its transcript files, or diff --git a/packages/agent-host/src/sdk/net.ts b/packages/agent-host/src/sdk/net.ts index 8bf997b6..21c12c4a 100644 --- a/packages/agent-host/src/sdk/net.ts +++ b/packages/agent-host/src/sdk/net.ts @@ -1,7 +1,7 @@ /** - * The host's own outbound HTTP (credential checks). Plain `fetch`: the - * bridge's Tor proxy covers its relay traffic only, not agent-side requests. - * A seam so tests never touch the network. + * The host's own outbound HTTP (credential checks, provider endpoints). + * Plain `fetch`: the bridge's Tor proxy covers its relay traffic only, not + * agent-side requests. A seam so tests never touch the network. */ export interface HttpResponse { @@ -24,3 +24,51 @@ export const httpGet: HttpGet = async (url, headers) => { const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15_000) }); return { status: res.status, text: await res.text() }; }; + +/** The most of a provider's answer that is read. Some providers describe + * every model at length (OpenRouter's list is a few megabytes); past this + * the answer is refused rather than buffered without bound. */ +export const MAX_PROVIDER_ANSWER_BYTES = 16 * 1024 * 1024; + +/** + * A GET to a provider profile's endpoint, carrying its token. A redirect is + * not followed but answered as its own status: it could lead the token to + * another host, or off https — only the URL the profile names may see it. + */ +export const providerGet: HttpGet = async (url, headers) => { + const res = await fetch(url, { method: 'GET', headers, redirect: 'manual', signal: AbortSignal.timeout(20_000) }); + return { status: res.status, text: await readCapped(res) }; +}; + +/** A POST to a provider profile's endpoint; redirects as for `providerGet`. */ +export const providerPost: HttpPost = async (url, headers, body) => { + const res = await fetch(url, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); + await res.body?.cancel(); + return { status: res.status }; +}; + +/** Outbound HTTP to provider profiles' endpoints, as drivers take it. */ +export interface ProviderHttp { + get: HttpGet; + post: HttpPost; +} + +export const providerHttp: ProviderHttp = { get: providerGet, post: providerPost }; + +async function readCapped(res: Response): Promise { + if (!res.body) return ''; + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_PROVIDER_ANSWER_BYTES) { + await reader.cancel(); + throw new Error('the answer is too large'); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/packages/agent-host/src/sdk/providerApi.ts b/packages/agent-host/src/sdk/providerApi.ts new file mode 100644 index 00000000..0aa13d10 --- /dev/null +++ b/packages/agent-host/src/sdk/providerApi.ts @@ -0,0 +1,72 @@ +/** + * The APIs a provider profile's endpoint can speak, as an agent speaks + * them: how it signs in, how its models are listed, and the smallest + * request that proves the token. A profile belongs to one agent, and a + * driver picks the API its agent uses — an endpoint compatible with one is + * not necessarily compatible with another. + */ +import type { HttpGet, HttpPost } from './net'; +import { providerApiRoot, readProviderModels, type EndpointModel } from './providerModels'; +import type { ProviderBinding } from './types'; + +export interface ProviderApi { + /** The headers that carry `token` on this API. */ + auth(token: string): Record; + /** The path under the endpoint's `/v1` root a one-token request goes to. */ + completionPath: string; +} + +/** OpenAI's API (chat completions), as OpenAI-compatible clients call it. */ +export const OPENAI_API: ProviderApi = { + auth: (token) => ({ authorization: `Bearer ${token}` }), + completionPath: 'chat/completions', +}; + +/** Anthropic's Messages API. The token goes in both headers Anthropic-style + * endpoints read it from: `x-api-key` (Anthropic's own) and + * `Authorization` (most gateways). */ +export const ANTHROPIC_API: ProviderApi = { + auth: (token) => ({ 'x-api-key': token, authorization: `Bearer ${token}`, 'anthropic-version': '2023-06-01' }), + completionPath: 'messages', +}; + +/** The models the endpoint lists, read the way `api` signs in. Rejects + * with the reason (see `readProviderModels`). */ +export function listModels(api: ProviderApi, baseUrl: string, token: string, get: HttpGet): Promise { + return readProviderModels(baseUrl, { headers: api.auth(token), httpGet: get }); +} + +/** + * What a check's HTTP status says about the token. 401/403: rejected. + * Success, or an error the provider only returns once it has accepted the + * credentials (a malformed request, a rate limit): valid. Anything else — + * 404 from a wrong base URL or an API the endpoint does not speak, a + * redirect, a 5xx — never reached the credential check, so it proves + * nothing either way. + */ +export function tokenVerdict(status: number): boolean | undefined { + if (status === 401 || status === 403) return false; + if ((status >= 200 && status < 300) || status === 400 || status === 422 || status === 429) return true; + return undefined; +} + +/** Check a profile's token with one token of `model` on `api`. Undefined + * when it could not be checked. */ +export async function checkToken( + api: ProviderApi, + provider: ProviderBinding, + model: string, + post: HttpPost, +): Promise { + const body = JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }); + try { + const res = await post( + `${providerApiRoot(provider.baseUrl)}/${api.completionPath}`, + { 'content-type': 'application/json', ...api.auth(provider.authToken) }, + body, + ); + return tokenVerdict(res.status); + } catch { + return undefined; + } +} diff --git a/packages/agent-host/src/sdk/providerModels.ts b/packages/agent-host/src/sdk/providerModels.ts index 1b053996..3cfe1918 100644 --- a/packages/agent-host/src/sdk/providerModels.ts +++ b/packages/agent-host/src/sdk/providerModels.ts @@ -151,41 +151,56 @@ export interface FetchProviderModelsOptions { } /** - * The models the endpoint at `baseUrl` serves, or `undefined` when they - * could not be read: unreachable, refused, an answer that is not a model - * list, or an empty one. Each failure is logged; none throws. + * The models the endpoint at `baseUrl` serves. Rejects with the reason, in + * words for a person, when there is no list: not a URL, unreachable, + * refused, an answer that is not a model list, or an empty one. */ -export async function fetchProviderModels( +export async function readProviderModels( baseUrl: string, - options: FetchProviderModelsOptions, -): Promise { - const { log, tag } = options; + options: Pick, +): Promise { let url: string; try { url = providerModelsUrl(baseUrl); } catch { - log(`${tag} '${baseUrl}' is not a URL; no model list`); - return undefined; + throw new Error(`'${baseUrl}' is not a URL`); } - let body: unknown; + let response: Awaited>; try { - const response = await options.httpGet(url, { + response = await options.httpGet(url, { ...options.headers, ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), }); - if (response.status < 200 || response.status >= 300) { - log(`${tag} ${baseUrl} answered ${response.status} for its model list`); - return undefined; - } - body = JSON.parse(response.text ?? '') as unknown; } catch (error) { - log(`${tag} could not read the model list from ${url}: ${error instanceof Error ? error.message : String(error)}`); - return undefined; + throw new Error(`${url} could not be read (${error instanceof Error ? error.message : String(error)})`); + } + if (response.status === 401 || response.status === 403) { + throw new Error(`the provider refused the token (HTTP ${response.status})`); + } + if (response.status < 200 || response.status >= 300) throw new Error(`${url} answered HTTP ${response.status}`); + let body: unknown; + try { + body = JSON.parse(response.text ?? '') as unknown; + } catch { + throw new Error(`${url} did not answer with a model list`); } const models = parseProviderModels(body); - if (models.length === 0) { - log(`${tag} ${baseUrl} listed no models`); + if (models.length === 0) throw new Error('it lists no models'); + return models; +} + +/** + * The models the endpoint at `baseUrl` serves, or `undefined` when they + * could not be read (see `readProviderModels`) — the reason is logged. + */ +export async function fetchProviderModels( + baseUrl: string, + options: FetchProviderModelsOptions, +): Promise { + try { + return await readProviderModels(baseUrl, options); + } catch (error) { + options.log(`${options.tag} no model list from ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`); return undefined; } - return models; } From 8736cb0fbce9b779d1704539626f6287e3e2adcd Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:27:29 +0200 Subject: [PATCH 6/6] feat: each agent's AI providers on a page of its own Provider profiles now belong to one agent, so the phone keeps them the way it keeps plugins and MCP servers: the machine's page lists the agents that take providers, each opening its own page, which says what a provider does for that agent (a session runs on it, or its models join the agent's list). Adding or editing opens a full-page editor with the endpoint, the token and the models; the http rule now names your own network. A profile saved before profiles had an agent shows on every agent's page, to be taken over with one tap or deleted. A new session offers only the chosen agent's own providers. Snapshots cover the list, the empty page and the editor. Co-Authored-By: Claude Code --- .../main/java/com/codedeck/plus/ui/Shell.kt | 9 +- .../plus/ui/screens/MachineProviders.kt | 422 --------------- .../plus/ui/screens/MachineSettings.kt | 26 +- .../plus/ui/screens/NewSessionScreen.kt | 5 +- .../plus/ui/screens/ProvidersScreen.kt | 506 ++++++++++++++++++ .../plus/ui/screens/SettingsScreen.kt | 8 +- .../main/java/uniffi/client_ffi/client_ffi.kt | 22 + .../com/codedeck/plus/ui/DesignFixtures.kt | 31 +- .../codedeck/plus/ui/DesignSnapshotTest.kt | 14 +- ...us.ui_DesignSnapshotTest_providers_add.png | Bin 0 -> 48436 bytes ...ignSnapshotTest_providers_claude_empty.png | Bin 0 -> 88098 bytes ...s.ui_DesignSnapshotTest_providers_edit.png | Bin 0 -> 58840 bytes ..._DesignSnapshotTest_providers_opencode.png | Bin 0 -> 72754 bytes 13 files changed, 604 insertions(+), 439 deletions(-) delete mode 100644 apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt create mode 100644 apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_add.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_claude_empty.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_edit.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt index 7454b382..551cfaf7 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt @@ -28,6 +28,7 @@ import com.codedeck.plus.ui.screens.MachineSettingsScreen import com.codedeck.plus.ui.screens.PairingScreen import com.codedeck.plus.ui.screens.McpScreen import com.codedeck.plus.ui.screens.PluginsScreen +import com.codedeck.plus.ui.screens.ProvidersScreen import com.codedeck.plus.ui.screens.SettingsScreen import com.codedeck.plus.ui.session.SessionScreen import com.codedeck.plus.ui.theme.Tokens @@ -50,6 +51,7 @@ private sealed interface Screen { /** One agent's plugins on a machine, opened from the machine's page. */ data class Plugins(val machine: String, val agent: String) : Screen data class Mcp(val machine: String, val agent: String) : Screen + data class Providers(val machine: String, val agent: String) : Screen } /** [Screen] as a flat string list: what [ScreenSaver] stores, and (joined) @@ -64,6 +66,7 @@ private fun Screen.parts(): List = when (this) { is Screen.Machine -> listOf("machine", machine) is Screen.Plugins -> listOf("plugins", machine, agent) is Screen.Mcp -> listOf("mcp", machine, agent) + is Screen.Providers -> listOf("providers", machine, agent) } private fun Screen.stateKey(): String = parts().joinToString("/") @@ -72,7 +75,7 @@ private fun Screen.stateKey(): String = parts().joinToString("/") * shallower one. */ private fun Screen.depth(): Int = when (this) { Screen.Sessions -> 0 - Screen.Logs, is Screen.Plugins, is Screen.Mcp -> 2 + Screen.Logs, is Screen.Plugins, is Screen.Mcp, is Screen.Providers -> 2 else -> 1 } @@ -90,6 +93,7 @@ private val ScreenSaver = listSaver( "machine" -> Screen.Machine(saved[1]) "plugins" -> Screen.Plugins(saved[1], saved[2]) "mcp" -> Screen.Mcp(saved[1], saved[2]) + "providers" -> Screen.Providers(saved[1], saved[2]) else -> Screen.Sessions } }, @@ -242,6 +246,7 @@ fun Shell( Screen.Logs -> Screen.Settings is Screen.Plugins -> Screen.Machine(current.machine) is Screen.Mcp -> Screen.Machine(current.machine) + is Screen.Providers -> Screen.Machine(current.machine) else -> Screen.Sessions }, ) @@ -262,11 +267,13 @@ fun Shell( core, current.machine, onBack = { go(Screen.Sessions) }, + onOpenProviders = { agent -> go(Screen.Providers(current.machine, agent)) }, onOpenPlugins = { agent -> go(Screen.Plugins(current.machine, agent)) }, onOpenMcp = { agent -> go(Screen.Mcp(current.machine, agent)) }, ) is Screen.Plugins -> PluginsScreen(core, current.machine, current.agent, onBack = { go(Screen.Machine(current.machine)) }) is Screen.Mcp -> McpScreen(core, current.machine, current.agent, onBack = { go(Screen.Machine(current.machine)) }) + is Screen.Providers -> ProvidersScreen(core, current.machine, current.agent, onBack = { go(Screen.Machine(current.machine)) }) Screen.Logs -> LogsScreen(onBack = { go(Screen.Settings) }) Screen.Pairing -> PairingScreen(core, onClose = { go(Screen.Sessions) }) is Screen.NewSession -> NewSessionScreen( diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt deleted file mode 100644 index fc689041..00000000 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt +++ /dev/null @@ -1,422 +0,0 @@ -package com.codedeck.plus.ui.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.input.PasswordVisualTransformation -import com.codedeck.plus.ui.components.Chip -import com.codedeck.plus.ui.components.Field -import com.codedeck.plus.ui.components.IconAction -import com.codedeck.plus.ui.components.PickerOption -import com.codedeck.plus.ui.components.PrimaryButton -import com.codedeck.plus.ui.components.QuietButton -import com.codedeck.plus.ui.components.SecondaryButton -import com.codedeck.plus.ui.components.SelectField -import com.codedeck.plus.ui.theme.Tokens -import uniffi.client_ffi.UniffiIntent -import uniffi.client_ffi.UniffiMachineSummary -import uniffi.client_ffi.UniffiProviderModelWrite -import uniffi.client_ffi.UniffiProviderProfileAck -import uniffi.client_ffi.UniffiProviderProfileInfo -import uniffi.client_ffi.UniffiProviderProfileWrite -import uniffi.client_ffi.UniffiTristate -import uniffi.client_ffi.isValidProviderBaseUrl -import uniffi.client_ffi.providerBaseUrlError - -/** Draft row of the models editor ('' label = omit on the wire). */ -private data class ModelRow(val id: String, val label: String) - -private val EMPTY_ROW = ModelRow("", "") - -/** - * Human-legible profile id from the label (house slug style), suffixed on - * collision so re-adding "Kimi K3" never silently overwrites a profile. Port - * of `MachineProviders.tsx`'s `profileIdFromLabel`. - */ -private fun profileIdFromLabel(label: String, taken: Set): String { - val base = label.lowercase().trim().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "profile" } - if (base !in taken) return base - var n = 2 - while ("$base-$n" in taken) n++ - return "$base-$n" -} - -/** Add-form prefills for the two providers the feature was built around. - * [fromProvider]: the provider lists its own models, so none are typed. */ -private data class Preset( - val name: String, - val baseUrl: String, - val models: List, - val defaultModel: String, - val fromProvider: Boolean, -) - -private val PRESETS = listOf( - Preset("Kimi K3", "https://api.moonshot.ai/anthropic", listOf(ModelRow("kimi-k3", "Kimi K3")), "kimi-k3", fromProvider = false), - Preset("OpenRouter", "https://openrouter.ai/api", listOf(EMPTY_ROW), "", fromProvider = true), -) - -/** - * Machine AI provider profiles (CDX-062) — port of `apps/mobile/src/ui/ - * screens/MachineProviders.tsx`. The caller gates this on - * `machine.capabilities.contains(CAP_CUSTOM_PROVIDERS)` before invoking it - * (mirrors the TSX's cap gate) — Compose has no rules-of-hooks constraint - * forcing the gate to live inside this composable. - * - * One disclosed narrowing from the TSX: `UniffiMachineSummary.providerProfiles` - * is a plain (non-optional) list on this FFI surface, so there is no "not yet - * answered" vs. "answered empty" distinction to render a "Loading provider - * profiles…" state for — the same narrowing `NewSessionScreen.kt` already - * accepted for this same field. - */ -@Composable -fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfileAck?, dispatch: (UniffiIntent) -> Unit) { - val profiles = machine.providerProfiles - - var formOpen by remember(machine.pubkeyHex) { mutableStateOf(false) } - /** Profile id being edited; null = the add form. */ - var editingId by remember(machine.pubkeyHex) { mutableStateOf(null) } - var label by remember(machine.pubkeyHex) { mutableStateOf("") } - var baseUrl by remember(machine.pubkeyHex) { mutableStateOf("") } - var token by remember(machine.pubkeyHex) { mutableStateOf("") } - var clearToken by remember(machine.pubkeyHex) { mutableStateOf(false) } - var models by remember(machine.pubkeyHex) { mutableStateOf(listOf(EMPTY_ROW)) } - /** The bridge reads the models from the provider instead of [models]. */ - var fromProvider by remember(machine.pubkeyHex) { mutableStateOf(true) } - var defaultModel by remember(machine.pubkeyHex) { mutableStateOf("") } - /** Which profile's Delete awaits its confirm step. */ - var confirmDelete by remember(machine.pubkeyHex) { mutableStateOf(null) } - var saving by remember(machine.pubkeyHex) { mutableStateOf(false) } - - LaunchedEffect(machine.pubkeyHex) { - dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) - } - - /** A save of the open form is waiting for the bridge's ack. */ - var awaitingSave by remember(machine.pubkeyHex) { mutableStateOf(false) } - val editingProfile = editingId?.let { id -> profiles.find { it.id == id } } - val validModels = models.filter { it.id.trim().isNotEmpty() } - val trimmedBaseUrl = baseUrl.trim() - // Only once there is something to check: an empty field is not an error yet. - val baseUrlValid = trimmedBaseUrl.isNotEmpty() && isValidProviderBaseUrl(trimmedBaseUrl) - val baseUrlError = trimmedBaseUrl.isNotEmpty() && !baseUrlValid - // Reading the provider's list takes its token: a new one, or the stored one. - val hasTokenToUse = token.trim().isNotEmpty() || (editingProfile?.hasToken == true && !clearToken) - val canSave = label.trim().isNotEmpty() && baseUrlValid && - (if (fromProvider) hasTokenToUse else validModels.isNotEmpty()) - // What the default-model picker offers: the typed list, or what the - // provider listed at the profile's last save. - val defaultChoices: List> = - if (fromProvider) { - editingProfile?.takeIf { it.modelsFromProvider }?.models.orEmpty().map { it.id to (it.label ?: it.id) } - } else { - validModels.map { m -> m.id.trim() to m.label.trim().ifEmpty { m.id.trim() } } - } - - fun resetForm() { - editingId = null - label = "" - baseUrl = "" - token = "" - clearToken = false - models = listOf(EMPTY_ROW) - fromProvider = true - defaultModel = "" - } - - LaunchedEffect(status) { - if (status == null) return@LaunchedEffect - saving = false - if (awaitingSave && status.state != "saving") { - awaitingSave = false - if (status.state == "saved") { - formOpen = false - resetForm() - } - } - } - - fun openAdd() { - resetForm() - formOpen = true - } - - fun openEdit(p: UniffiProviderProfileInfo) { - editingId = p.id - label = p.label - baseUrl = p.baseUrl - token = "" - clearToken = false - models = if (p.modelsFromProvider) listOf(EMPTY_ROW) else p.models.map { ModelRow(it.id, it.label ?: "") } - fromProvider = p.modelsFromProvider - defaultModel = p.defaultModel ?: "" - formOpen = true - } - - fun applyPreset(preset: Preset) { - label = preset.name - baseUrl = preset.baseUrl - models = preset.models - defaultModel = preset.defaultModel - fromProvider = preset.fromProvider - } - - fun save() { - val profileId = editingId ?: profileIdFromLabel(label, profiles.map { it.id }.toSet()) - val wireModels = validModels.map { m -> - UniffiProviderModelWrite(id = m.id.trim(), label = m.label.trim().ifEmpty { null }) - } - val authToken = when { - clearToken -> UniffiTristate.Clear - token.trim().isNotEmpty() -> UniffiTristate.Set(token.trim()) - else -> UniffiTristate.Keep - } - // A provider-listed default the provider no longer lists is dropped - // by the bridge, which reads the list anew. - val resolvedDefault = when { - defaultModel.isEmpty() -> null - fromProvider || wireModels.any { it.id == defaultModel } -> defaultModel - else -> null - } - saving = true - dispatch( - UniffiIntent.SetProviderProfile( - machine = machine.pubkeyHex, - profileId = profileId, - profile = UniffiProviderProfileWrite( - label = label.trim(), - baseUrl = baseUrl.trim(), - authToken = authToken, - models = if (fromProvider) emptyList() else wireModels, - modelsFromProvider = fromProvider, - defaultModel = resolvedDefault, - ), - ), - ) - // The secret leaves component state at once; the rest of the form - // stays open until the bridge confirms, so a failed save can be - // corrected and retried instead of retyped (see LaunchedEffect(status)). - token = "" - clearToken = false - awaitingSave = true - } - - /** Read a provider's model list again, changing nothing else. */ - fun refreshModels(p: UniffiProviderProfileInfo) { - saving = true - dispatch( - UniffiIntent.SetProviderProfile( - machine = machine.pubkeyHex, - profileId = p.id, - profile = UniffiProviderProfileWrite( - label = p.label, - baseUrl = p.baseUrl, - authToken = UniffiTristate.Keep, - models = emptyList(), - modelsFromProvider = true, - defaultModel = p.defaultModel, - ), - ), - ) - } - - fun deleteProfile(profileId: String) { - confirmDelete = null - saving = true - dispatch(UniffiIntent.SetProviderProfile(machine = machine.pubkeyHex, profileId = profileId, profile = null)) - if (editingId == profileId) { - formOpen = false - resetForm() - } - } - - Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space3)) { - Text( - "Providers and gateways (OpenRouter, Kimi, your own router, …) kept on the bridge. A new " + - "session can run on one, when its agent speaks the provider's API. Their tokens stay on " + - "the bridge; this phone never stores them.", - color = Tokens.TextMuted, - fontSize = Tokens.TextSm, - ) - - profiles.forEach { p -> - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Tokens.RadiusLg)) - .background(Tokens.SurfaceInput) - .padding(Tokens.Space3), - verticalArrangement = Arrangement.spacedBy(Tokens.Space1), - ) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Text(p.label, color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) - Chip( - if (p.hasToken) "token set" else "no token", - color = if (p.hasToken) Tokens.Success else Tokens.TextDim, - border = if (p.hasToken) Tokens.Success.copy(alpha = 0.4f) else Tokens.Border, - ) - } - Text(p.baseUrl, color = Tokens.TextMuted, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono) - Text( - "${p.models.size} ${if (p.models.size == 1) "model" else "models"}" + - (if (p.modelsFromProvider) " from the provider" else "") + - (p.defaultModel?.let { ", default $it" } ?: ""), - color = Tokens.TextMuted, - fontSize = Tokens.TextSm, - ) - if (confirmDelete == p.id) { - Text( - "Delete ${p.label} from the bridge? Sessions bound to it will fail on their " + - "next restart instead of silently falling back to Anthropic.", - color = Tokens.Danger, - fontSize = Tokens.TextSm, - ) - Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - SecondaryButton("Delete provider", onClick = { deleteProfile(p.id) }, danger = true) - QuietButton("Cancel", onClick = { confirmDelete = null }) - } - } else { - Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { - QuietButton("Edit", onClick = { openEdit(p) }) - if (p.modelsFromProvider && p.hasToken) { - QuietButton("Refresh models", onClick = { refreshModels(p) }, enabled = !saving) - } - QuietButton("Delete", onClick = { confirmDelete = p.id }, danger = true) - } - } - } - } - - if (!formOpen) { - SecondaryButton("Add provider", onClick = ::openAdd) - } else { - Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - if (editingId == null) { - Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { - PRESETS.forEach { preset -> QuietButton(preset.name, onClick = { applyPreset(preset) }) } - } - } - Field(value = label, onValueChange = { label = it }, label = "Name", placeholder = "Kimi K3") - Field( - value = baseUrl, - onValueChange = { baseUrl = it }, - label = "Base URL", - placeholder = "https://api.moonshot.ai/anthropic", - mono = true, - isError = baseUrlError, - supporting = if (baseUrlError) providerBaseUrlError() else null, - ) - Field( - value = token, - onValueChange = { token = it }, - label = "API token", - placeholder = if (editingProfile?.hasToken == true) "unchanged" else "sk-…", - visualTransformation = PasswordVisualTransformation(), - ) - if (editingProfile?.hasToken == true) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Checkbox( - checked = clearToken, - onCheckedChange = { - clearToken = it - if (it) token = "" - }, - ) - Text("Delete the stored token when saving", color = Tokens.TextMuted, fontSize = Tokens.TextSm) - } - } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Checkbox(checked = fromProvider, onCheckedChange = { fromProvider = it }) - Text("Read the models from the provider", color = Tokens.Text, fontSize = Tokens.TextSm) - } - if (fromProvider) { - Text( - "The bridge asks the provider for its model list each time you save, with the token above.", - color = Tokens.TextMuted, - fontSize = Tokens.TextSm, - ) - } else models.forEachIndexed { i, row -> - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), - ) { - Field( - value = row.id, - onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(id = v) else m } }, - placeholder = "model id", - mono = true, - modifier = Modifier.weight(1f), - ) - Field( - value = row.label, - onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(label = v) else m } }, - placeholder = "label", - modifier = Modifier.weight(1f), - ) - IconAction( - Icons.Outlined.Close, - "Remove model", - onClick = { models = models.filterIndexed { j, _ -> j != i } }, - tint = if (models.size > 1) Tokens.TextMuted else Tokens.TextDim, - // A provider keeps at least one model. - enabled = models.size > 1, - ) - } - } - if (!fromProvider) QuietButton("Add model", onClick = { models = models + EMPTY_ROW }) - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Text("Default model", color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) - SelectField( - options = buildList { - add(PickerOption("", "First model")) - defaultChoices.forEach { (id, name) -> add(PickerOption(id, name)) } - }, - selected = defaultModel, - onSelect = { defaultModel = it }, - ) - } - - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - PrimaryButton("Save on the bridge", onClick = ::save, enabled = canSave, modifier = Modifier.weight(1f)) - QuietButton("Cancel", onClick = { - formOpen = false - resetForm() - }) - } - } - } - - if (status != null) { - val text = when (status.state) { - "saving" -> "Saving on the bridge…" - "saved" -> "Saved" + when (status.tokenValid) { - true -> ", token valid" - false -> ", token rejected" - null -> "" - } - "failed" -> "Saving failed: ${status.error ?: "unknown error"}" - else -> "" - } - Text(text, color = if (status.state == "failed") Tokens.Danger else Tokens.TextMuted, fontSize = Tokens.TextSm) - } - } -} diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt index 92fec7ef..d5ee7e5f 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt @@ -111,9 +111,9 @@ internal fun shortKey(key: String): String = if (key.length <= 20) key else "${k /** * One machine's page: who it is and whether it is up, what its new sessions * start with, the relays it is reached over, its direct link, the - * credentials and AI providers kept on it, its agents' plugins (each on a - * page of its own, opened through [onOpenPlugins]; MCP servers through - * [onOpenMcp]), and forgetting it. Pure — the + * credentials kept on it, its agents' AI providers, plugins and MCP + * servers (each agent's on a page of its own, opened through + * [onOpenProviders], [onOpenPlugins] and [onOpenMcp]), and forgetting it. Pure — the * caller supplies the machine and a dispatcher — so it renders the same in * a snapshot. */ @@ -126,14 +126,15 @@ fun MachineSettingsContent( now: Long, dispatch: (UniffiIntent) -> Unit, onBack: () -> Unit, + onOpenProviders: (agent: String) -> Unit, onOpenPlugins: (agent: String) -> Unit, onOpenMcp: (agent: String) -> Unit, ) { - // The model pickers need each agent's list, and the plugin rows their - // counts: ask for them on opening. + // The model pickers need each agent's list, and the provider and plugin + // rows their counts: ask for them on opening. LaunchedEffect(machine.pubkeyHex) { machine.agents.filter { it.supportsModels }.forEach { dispatch(UniffiIntent.RequestModels(machine.pubkeyHex, it.id)) } - if (machine.agents.any { it.supportsProviders }) dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) + if (providerAgents(machine).isNotEmpty()) dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) machine.agents.filter { it.supportsPlugins }.forEach { dispatch(UniffiIntent.RequestPlugins(machine.pubkeyHex, it.id, available = false)) } @@ -158,9 +159,14 @@ fun MachineSettingsContent( GroupBody { MachineCredentials(machine, credentialsStatus, dispatch) } } } - if (machine.agents.any { it.supportsProviders }) { - Group(title = "AI providers") { - GroupBody { MachineProviders(machine, providerProfileStatus, dispatch) } + val providerAgents = providerAgents(machine) + if (providerAgents.isNotEmpty()) { + Group(title = "AI providers", footer = "API endpoints an agent can use: a service, or a gateway of your own. Each is kept for one agent.") { + providerAgents.forEachIndexed { i, agent -> + if (i > 0) Divider() + val count = machine.providerProfiles.count { it.agent == agent.id } + NavRow(agent.displayName, onClick = { onOpenProviders(agent.id) }, value = if (count == 1) "1 provider" else "$count providers") + } } } val pluginAgents = machine.agents.filter { it.supportsPlugins } @@ -411,6 +417,7 @@ fun MachineSettingsScreen( core: CoreHost, pubkey: String, onBack: () -> Unit, + onOpenProviders: (agent: String) -> Unit, onOpenPlugins: (agent: String) -> Unit, onOpenMcp: (agent: String) -> Unit, ) { @@ -426,6 +433,7 @@ fun MachineSettingsScreen( now = System.currentTimeMillis(), dispatch = { intent -> scope.launch { core.dispatch(intent) } }, onBack = onBack, + onOpenProviders = onOpenProviders, onOpenPlugins = onOpenPlugins, onOpenMcp = onOpenMcp, ) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt index 6b7d4a7a..b2bdd07a 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt @@ -161,9 +161,10 @@ internal fun NewSessionBody( val newFolderPath = newFolder.trim() val canCreate = agent != null && (folderChoice != NEW_FOLDER || newFolderPath.isNotEmpty()) - // Custom provider profiles only for an agent that can use them. + // The agent's own provider profiles, when a session can be bound to one + // (an agent whose profiles add models offers them in the model list). val providerProfiles: List = - if (agent?.supportsProviders == true) machine.providerProfiles else emptyList() + if (agent?.supportsProviders == true) machine.providerProfiles.filter { it.agent == agentId } else emptyList() val activeProfile = if (providerId != "") providerProfiles.find { it.id == providerId } else null // Each agent has its own model list — the Model picker never mixes them. val agentModels = machine.models.firstOrNull { it.agent == agentId } diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt new file mode 100644 index 00000000..1e0b2e1e --- /dev/null +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt @@ -0,0 +1,506 @@ +package com.codedeck.plus.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import com.codedeck.plus.core.CoreHost +import com.codedeck.plus.ui.components.Chip +import com.codedeck.plus.ui.components.ErrorNote +import com.codedeck.plus.ui.components.Field +import com.codedeck.plus.ui.components.Group +import com.codedeck.plus.ui.components.GroupBody +import com.codedeck.plus.ui.components.IconAction +import com.codedeck.plus.ui.components.Page +import com.codedeck.plus.ui.components.PickerOption +import com.codedeck.plus.ui.components.PrimaryButton +import com.codedeck.plus.ui.components.QuietButton +import com.codedeck.plus.ui.components.SecondaryButton +import com.codedeck.plus.ui.components.SelectField +import com.codedeck.plus.ui.components.ValueRow +import com.codedeck.plus.ui.components.machineLabel +import com.codedeck.plus.ui.theme.Tokens +import kotlinx.coroutines.launch +import uniffi.client_ffi.UniffiAgent +import uniffi.client_ffi.UniffiIntent +import uniffi.client_ffi.UniffiMachineSummary +import uniffi.client_ffi.UniffiProviderModelWrite +import uniffi.client_ffi.UniffiProviderProfileAck +import uniffi.client_ffi.UniffiProviderProfileInfo +import uniffi.client_ffi.UniffiProviderProfileWrite +import uniffi.client_ffi.UniffiTristate +import uniffi.client_ffi.isValidProviderBaseUrl +import uniffi.client_ffi.providerBaseUrlError + +/** Draft row of the models editor ('' label = omit on the wire). */ +private data class ModelRow(val id: String, val label: String) + +private val EMPTY_ROW = ModelRow("", "") + +/** + * Human-legible profile id from the label (house slug style), suffixed on + * collision so re-adding "Kimi K3" never silently overwrites a profile. + */ +private fun profileIdFromLabel(label: String, taken: Set): String { + val base = label.lowercase().trim().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "profile" } + if (base !in taken) return base + var n = 2 + while ("$base-$n" in taken) n++ + return "$base-$n" +} + +/** Add-form prefills for two common providers. [fromProvider]: the + * provider lists its own models, so none are typed. */ +private data class Preset( + val name: String, + val baseUrl: String, + val models: List, + val defaultModel: String, + val fromProvider: Boolean, +) + +private val PRESETS = listOf( + Preset("Kimi K3", "https://api.moonshot.ai/anthropic", listOf(ModelRow("kimi-k3", "Kimi K3")), "kimi-k3", fromProvider = false), + Preset("OpenRouter", "https://openrouter.ai/api", listOf(EMPTY_ROW), "", fromProvider = true), +) + +/** The agents a provider can be for. */ +internal fun providerAgents(machine: UniffiMachineSummary): List = + machine.agents.filter { it.supportsProviders || it.supportsProviderModels } + +/** What a provider does for [agent]. */ +internal fun providerUse(agent: UniffiAgent): String = + if (agent.supportsProviderModels) { + "Its models join ${agent.displayName}'s model list, beside the providers ${agent.displayName} already has." + } else { + "A new ${agent.displayName} session can run on it instead of ${agent.displayName}'s own account." + } + +/** What the open editor is: a new provider, or the profile with this id. */ +sealed interface ProviderEditor { + data object New : ProviderEditor + data class Existing(val id: String) : ProviderEditor +} + +/** + * One agent's AI providers on a machine — its provider profiles, kept per + * agent like its plugins and MCP servers: an endpoint that speaks one + * agent's API need not speak another's. A list page with an Add button; + * adding or editing opens the editor in its place, and Back returns. + * + * A profile saved before profiles had an agent is offered on every agent's + * page, to be taken over with one tap. + */ +@Composable +fun ProvidersContent( + machine: UniffiMachineSummary, + agentId: String, + status: UniffiProviderProfileAck?, + dispatch: (UniffiIntent) -> Unit, + onBack: () -> Unit, + /** Opens straight on the editor (for a snapshot). */ + startEditing: ProviderEditor? = null, + /** The core's base URL rule — a native call, so a snapshot, which cannot + * load the core, passes its own. */ + validBaseUrl: (String) -> Boolean = ::isValidProviderBaseUrl, +) { + val agent = machine.agents.firstOrNull { it.id == agentId } + val agentName = agent?.displayName ?: agentId + val known = providerAgents(machine).map { it.id }.toSet() + val own = machine.providerProfiles.filter { it.agent == agentId } + // Profiles from before profiles named their agent. + val unassigned = machine.providerProfiles.filter { it.agent !in known } + + var editor by remember(machine.pubkeyHex, agentId) { mutableStateOf(startEditing) } + var confirmDelete by remember(machine.pubkeyHex, agentId) { mutableStateOf(null) } + /** A change made from the list (refresh, take over, delete) awaits its ack. */ + var busy by remember(machine.pubkeyHex, agentId) { mutableStateOf(false) } + + LaunchedEffect(machine.pubkeyHex) { dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) } + LaunchedEffect(status) { if (status != null && status.state != "saving") busy = false } + + fun write(profileId: String, profile: UniffiProviderProfileWrite?) { + busy = true + dispatch(UniffiIntent.SetProviderProfile(machine = machine.pubkeyHex, profileId = profileId, profile = profile)) + } + + /** The same profile for [forAgent], its token kept and its models read + * again when they are the provider's. */ + fun rewrite(p: UniffiProviderProfileInfo, forAgent: String) = UniffiProviderProfileWrite( + agent = forAgent, + label = p.label, + baseUrl = p.baseUrl, + authToken = UniffiTristate.Keep, + models = if (p.modelsFromProvider) emptyList() else p.models.map { UniffiProviderModelWrite(it.id, it.label) }, + modelsFromProvider = p.modelsFromProvider, + defaultModel = p.defaultModel, + ) + + val open = editor + if (open != null) { + ProviderEditorPage( + machine = machine, + agentId = agentId, + agentName = agentName, + editing = (open as? ProviderEditor.Existing)?.let { e -> own.firstOrNull { it.id == e.id } }, + status = status, + dispatch = dispatch, + validBaseUrl = validBaseUrl, + onDone = { editor = null }, + ) + return + } + + Page( + title = "$agentName providers", + subtitle = machineLabel(machine.name), + onBack = onBack, + bottomBar = { PrimaryButton("Add a provider", onClick = { editor = ProviderEditor.New }, modifier = Modifier.fillMaxWidth()) }, + ) { + if (own.isEmpty()) { + Group { + GroupBody { + Text("No providers yet", color = Tokens.Text, fontSize = Tokens.TextLg) + Text( + "A provider is an API endpoint $agentName can use: a service such as OpenRouter or Kimi, " + + "or a gateway of your own, on this machine or your network. " + agent?.let(::providerUse).orEmpty(), + color = Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) + } + } + } else { + Group(footer = agent?.let { providerUse(it) + " Tokens stay on the machine; this phone never stores them." }) { + own.forEachIndexed { i, p -> + if (i > 0) Divider() + GroupBody { + ProfileSummary(p) + if (confirmDelete == p.id) { + Text( + if (agent?.supportsProviderModels == true) { + "Delete ${p.label}? Its models leave $agentName's list." + } else { + "Delete ${p.label}? Sessions on it fail at their next restart instead of " + + "falling back to $agentName's own account." + }, + color = Tokens.Danger, + fontSize = Tokens.TextSm, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + SecondaryButton("Delete provider", onClick = { + confirmDelete = null + write(p.id, null) + }, danger = true) + QuietButton("Cancel", onClick = { confirmDelete = null }) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { + QuietButton("Edit", onClick = { editor = ProviderEditor.Existing(p.id) }) + if (p.modelsFromProvider && p.hasToken) { + QuietButton("Refresh models", onClick = { write(p.id, rewrite(p, agentId)) }, enabled = !busy) + } + QuietButton("Delete", onClick = { confirmDelete = p.id }, danger = true) + } + } + } + } + } + } + + if (unassigned.isNotEmpty()) { + Group( + title = "Saved without an agent", + footer = "Saved before each provider was kept for one agent, so no agent uses them. " + + "Use one for $agentName if it speaks the API $agentName uses.", + ) { + unassigned.forEachIndexed { i, p -> + if (i > 0) Divider() + GroupBody { + ProfileSummary(p) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { + QuietButton("Use for $agentName", onClick = { write(p.id, rewrite(p, agentId)) }, enabled = !busy && p.hasToken) + QuietButton("Delete", onClick = { write(p.id, null) }, danger = true, enabled = !busy) + } + } + } + } + } + + ProfileStatus(status) + } +} + +@Composable +private fun ProfileSummary(p: UniffiProviderProfileInfo) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Text(p.label, color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) + Chip( + if (p.hasToken) "token set" else "no token", + color = if (p.hasToken) Tokens.Success else Tokens.TextDim, + border = if (p.hasToken) Tokens.Success.copy(alpha = 0.4f) else Tokens.Border, + ) + } + Text(p.baseUrl, color = Tokens.TextMuted, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono) + Text( + "${p.models.size} ${if (p.models.size == 1) "model" else "models"}" + + (if (p.modelsFromProvider) " from the provider" else "") + + (p.defaultModel?.let { ", default $it" } ?: ""), + color = Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) +} + +/** The last save's outcome, in words. */ +@Composable +private fun ProfileStatus(status: UniffiProviderProfileAck?) { + if (status == null) return + when (status.state) { + "saving" -> Text("Saving on the machine…", color = Tokens.TextMuted, fontSize = Tokens.TextSm) + "saved" -> Text( + "Saved" + when (status.tokenValid) { + true -> ", token valid" + false -> ", but the provider rejected the token" + null -> "" + }, + color = if (status.tokenValid == false) Tokens.Warn else Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) + "failed" -> ErrorNote("Saving failed: ${status.error ?: "unknown error"}") + } +} + +/** The editor of one provider: a new one when [editing] is null. Its token + * leaves the form once sent; the rest stays until the machine confirms, so + * a failed save can be corrected instead of retyped. */ +@Composable +private fun ProviderEditorPage( + machine: UniffiMachineSummary, + agentId: String, + agentName: String, + editing: UniffiProviderProfileInfo?, + status: UniffiProviderProfileAck?, + dispatch: (UniffiIntent) -> Unit, + validBaseUrl: (String) -> Boolean, + onDone: () -> Unit, +) { + val key = editing?.id ?: "" + var label by remember(key) { mutableStateOf(editing?.label.orEmpty()) } + var baseUrl by remember(key) { mutableStateOf(editing?.baseUrl.orEmpty()) } + var token by remember(key) { mutableStateOf("") } + var clearToken by remember(key) { mutableStateOf(false) } + /** The machine reads the models from the provider instead of [models]. */ + var fromProvider by remember(key) { mutableStateOf(editing?.modelsFromProvider ?: true) } + var models by remember(key) { + mutableStateOf(editing?.takeIf { !it.modelsFromProvider }?.models?.map { ModelRow(it.id, it.label.orEmpty()) } ?: listOf(EMPTY_ROW)) + } + var defaultModel by remember(key) { mutableStateOf(editing?.defaultModel.orEmpty()) } + var awaitingSave by remember(key) { mutableStateOf(false) } + + LaunchedEffect(status) { + if (awaitingSave && status != null && status.state != "saving") { + awaitingSave = false + if (status.state == "saved") onDone() + } + } + + val validModels = models.filter { it.id.trim().isNotEmpty() } + val trimmedBaseUrl = baseUrl.trim() + // Only once there is something to check: an empty field is not an error yet. + val baseUrlValid = trimmedBaseUrl.isNotEmpty() && validBaseUrl(trimmedBaseUrl) + val baseUrlError = trimmedBaseUrl.isNotEmpty() && !baseUrlValid + // Reading the provider's list takes its token: a new one, or the stored one. + val hasTokenToUse = token.trim().isNotEmpty() || (editing?.hasToken == true && !clearToken) + val canSave = !awaitingSave && label.trim().isNotEmpty() && baseUrlValid && + (if (fromProvider) hasTokenToUse else validModels.isNotEmpty()) + // What the default-model picker offers: the typed list, or what the + // provider listed at the last save. + val defaultChoices: List> = + if (fromProvider) { + editing?.takeIf { it.modelsFromProvider }?.models.orEmpty().map { it.id to (it.label ?: it.id) } + } else { + validModels.map { m -> m.id.trim() to m.label.trim().ifEmpty { m.id.trim() } } + } + + fun save() { + val profileId = editing?.id ?: profileIdFromLabel(label, machine.providerProfiles.map { it.id }.toSet()) + val wireModels = validModels.map { m -> UniffiProviderModelWrite(id = m.id.trim(), label = m.label.trim().ifEmpty { null }) } + val authToken = when { + clearToken -> UniffiTristate.Clear + token.trim().isNotEmpty() -> UniffiTristate.Set(token.trim()) + else -> UniffiTristate.Keep + } + // A provider-listed default the provider no longer lists is dropped + // by the machine, which reads the list anew. + val resolvedDefault = when { + defaultModel.isEmpty() -> null + fromProvider || wireModels.any { it.id == defaultModel } -> defaultModel + else -> null + } + dispatch( + UniffiIntent.SetProviderProfile( + machine = machine.pubkeyHex, + profileId = profileId, + profile = UniffiProviderProfileWrite( + agent = agentId, + label = label.trim(), + baseUrl = trimmedBaseUrl, + authToken = authToken, + models = if (fromProvider) emptyList() else wireModels, + modelsFromProvider = fromProvider, + defaultModel = resolvedDefault, + ), + ), + ) + token = "" + clearToken = false + awaitingSave = true + } + + Page( + title = editing?.let { "Edit ${it.label}" } ?: "Add a provider", + subtitle = "$agentName · ${machineLabel(machine.name)}", + onBack = onDone, + bottomBar = { + PrimaryButton(if (awaitingSave) "Saving…" else "Save on the machine", onClick = ::save, enabled = canSave, modifier = Modifier.fillMaxWidth()) + }, + ) { + if (editing == null) { + Group(title = "Start from") { + GroupBody { + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { + PRESETS.forEach { preset -> + QuietButton(preset.name, onClick = { + label = preset.name + baseUrl = preset.baseUrl + models = preset.models + defaultModel = preset.defaultModel + fromProvider = preset.fromProvider + }) + } + } + } + } + } + Group( + title = "Endpoint", + footer = "It must speak the API $agentName uses. http:// works only for this machine or an address on your own network.", + ) { + GroupBody { + Field(value = label, onValueChange = { label = it }, label = "Name", placeholder = "My gateway") + Field( + value = baseUrl, + onValueChange = { baseUrl = it }, + label = "Base URL", + placeholder = "https://api.example.com", + mono = true, + isError = baseUrlError, + supporting = if (baseUrlError) providerBaseUrlError() else null, + ) + Field( + value = token, + onValueChange = { token = it }, + label = "API token", + placeholder = if (editing?.hasToken == true) "unchanged" else "sk-…", + visualTransformation = PasswordVisualTransformation(), + ) + if (editing?.hasToken == true) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Checkbox( + checked = clearToken, + onCheckedChange = { + clearToken = it + if (it) token = "" + }, + ) + Text("Delete the stored token when saving", color = Tokens.TextMuted, fontSize = Tokens.TextSm) + } + } + } + } + Group( + title = "Models", + footer = if (fromProvider) "The machine asks the provider for its model list every time you save, with the token above." else null, + ) { + GroupBody { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Checkbox(checked = fromProvider, onCheckedChange = { fromProvider = it }) + Text("Read the models from the provider", color = Tokens.Text, fontSize = Tokens.TextMd) + } + if (!fromProvider) { + models.forEachIndexed { i, row -> + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), + ) { + Field( + value = row.id, + onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(id = v) else m } }, + placeholder = "model id", + mono = true, + modifier = Modifier.weight(1f), + ) + Field( + value = row.label, + onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(label = v) else m } }, + placeholder = "label", + modifier = Modifier.weight(1f), + ) + IconAction( + Icons.Outlined.Close, + "Remove model", + onClick = { models = models.filterIndexed { j, _ -> j != i } }, + tint = if (models.size > 1) Tokens.TextMuted else Tokens.TextDim, + // A provider keeps at least one model. + enabled = models.size > 1, + ) + } + } + QuietButton("Add model", onClick = { models = models + EMPTY_ROW }) + } + } + Divider() + ValueRow("Default model") { + SelectField( + options = buildList { + add(PickerOption("", "First model")) + defaultChoices.forEach { (id, name) -> add(PickerOption(id, name)) } + }, + selected = defaultModel, + onSelect = { defaultModel = it }, + ) + } + } + ProfileStatus(status.takeIf { awaitingSave || it?.state == "failed" }) + } +} + +/** [ProvidersContent] for one agent of the machine [pubkey], from the core; + * closes when the machine goes away. */ +@Composable +fun ProvidersScreen(core: CoreHost, pubkey: String, agentId: String, onBack: () -> Unit) { + val ui by core.ui.collectAsState() + val scope = rememberCoroutineScope() + val machine = machineOrLeave(core, pubkey, onGone = onBack) ?: return + ProvidersContent( + machine, + agentId, + status = ui?.providerProfileStatus?.get(pubkey), + dispatch = { intent -> scope.launch { core.dispatch(intent) } }, + onBack = onBack, + ) +} diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt index 1d68f46f..c322aacf 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt @@ -89,6 +89,8 @@ internal sealed interface SettingsPage { data class Plugins(val pubkey: String, val agent: String) : SettingsPage /** One agent's MCP servers on a machine, opened from the machine's page. */ data class Mcp(val pubkey: String, val agent: String) : SettingsPage + /** One agent's AI providers on a machine, opened from the machine's page. */ + data class Providers(val pubkey: String, val agent: String) : SettingsPage data object Appearance : SettingsPage data object Notifications : SettingsPage data object Connection : SettingsPage @@ -102,6 +104,7 @@ internal sealed interface SettingsPage { is Machine -> "machine:$pubkey" is Plugins -> "plugins:$pubkey:$agent" is Mcp -> "mcp:$pubkey:$agent" + is Providers -> "providers:$pubkey:$agent" Appearance -> "appearance" Notifications -> "notifications" Connection -> "connection" @@ -115,7 +118,7 @@ internal sealed interface SettingsPage { val depth: Int get() = when (this) { Hub -> 0 - is Plugins, is Mcp -> 2 + is Plugins, is Mcp, is Providers -> 2 else -> 1 } @@ -124,6 +127,7 @@ internal sealed interface SettingsPage { saved.startsWith("machine:") -> Machine(saved.removePrefix("machine:")) saved.startsWith("plugins:") -> saved.split(':').let { Plugins(it[1], it.drop(2).joinToString(":")) } saved.startsWith("mcp:") -> saved.split(':').let { Mcp(it[1], it.drop(2).joinToString(":")) } + saved.startsWith("providers:") -> saved.split(':').let { Providers(it[1], it.drop(2).joinToString(":")) } saved == "appearance" -> Appearance saved == "notifications" -> Notifications saved == "connection" -> Connection @@ -227,6 +231,7 @@ fun SettingsScreen( now = System.currentTimeMillis(), dispatch = ::dispatch, onBack = toHub, + onOpenProviders = { agent -> open(SettingsPage.Providers(machine.pubkeyHex, agent)) }, onOpenPlugins = { agent -> open(SettingsPage.Plugins(machine.pubkeyHex, agent)) }, onOpenMcp = { agent -> open(SettingsPage.Mcp(machine.pubkeyHex, agent)) }, ) @@ -234,6 +239,7 @@ fun SettingsScreen( } is SettingsPage.Plugins -> PluginsScreen(core, page.pubkey, page.agent, onBack = back) is SettingsPage.Mcp -> McpScreen(core, page.pubkey, page.agent, onBack = back) + is SettingsPage.Providers -> ProvidersScreen(core, page.pubkey, page.agent, onBack = back) SettingsPage.Appearance -> AppearancePage(view, ::dispatch, toHub) SettingsPage.Notifications -> NotificationsPage(view, ::dispatch, toHub) SettingsPage.Connection -> { diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index f0e93f58..7897d0b9 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -4458,6 +4458,11 @@ data class UniffiAgent ( , var `supportsProviders`: kotlin.Boolean , + /** + * The agent's provider profiles add models to its own list. + */ + var `supportsProviderModels`: kotlin.Boolean + , var `supportsGsd`: kotlin.Boolean , var `supportsInterrupt`: kotlin.Boolean @@ -4505,6 +4510,7 @@ public object FfiConverterTypeUniffiAgent: FfiConverterRustBuffer { FfiConverterBoolean.read(buf), FfiConverterBoolean.read(buf), FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), FfiConverterSequenceTypeUniffiCredentialStatus.read(buf), ) } @@ -4519,6 +4525,7 @@ public object FfiConverterTypeUniffiAgent: FfiConverterRustBuffer { FfiConverterBoolean.allocationSize(value.`supportsModels`) + FfiConverterBoolean.allocationSize(value.`supportsUsage`) + FfiConverterBoolean.allocationSize(value.`supportsProviders`) + + FfiConverterBoolean.allocationSize(value.`supportsProviderModels`) + FfiConverterBoolean.allocationSize(value.`supportsGsd`) + FfiConverterBoolean.allocationSize(value.`supportsInterrupt`) + FfiConverterBoolean.allocationSize(value.`supportsCommands`) + @@ -4538,6 +4545,7 @@ public object FfiConverterTypeUniffiAgent: FfiConverterRustBuffer { FfiConverterBoolean.write(value.`supportsModels`, buf) FfiConverterBoolean.write(value.`supportsUsage`, buf) FfiConverterBoolean.write(value.`supportsProviders`, buf) + FfiConverterBoolean.write(value.`supportsProviderModels`, buf) FfiConverterBoolean.write(value.`supportsGsd`, buf) FfiConverterBoolean.write(value.`supportsInterrupt`, buf) FfiConverterBoolean.write(value.`supportsCommands`, buf) @@ -6775,6 +6783,12 @@ public object FfiConverterTypeUniffiProviderProfileAck: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UniffiProviderProfileWrite { return UniffiProviderProfileWrite( + FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterTypeUniffiTristate.read(buf), @@ -6883,6 +6903,7 @@ public object FfiConverterTypeUniffiProviderProfileWrite: FfiConverterRustBuffer } override fun allocationSize(value: UniffiProviderProfileWrite) = ( + FfiConverterString.allocationSize(value.`agent`) + FfiConverterString.allocationSize(value.`label`) + FfiConverterString.allocationSize(value.`baseUrl`) + FfiConverterTypeUniffiTristate.allocationSize(value.`authToken`) + @@ -6892,6 +6913,7 @@ public object FfiConverterTypeUniffiProviderProfileWrite: FfiConverterRustBuffer ) override fun write(value: UniffiProviderProfileWrite, buf: ByteBuffer) { + FfiConverterString.write(value.`agent`, buf) FfiConverterString.write(value.`label`, buf) FfiConverterString.write(value.`baseUrl`, buf) FfiConverterTypeUniffiTristate.write(value.`authToken`, buf) diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt index 9bae6011..66005fe8 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt @@ -35,6 +35,7 @@ import uniffi.client_ffi.UniffiPluginNotice import uniffi.client_ffi.UniffiCredentialStatus import uniffi.client_ffi.UniffiMachineSummary import uniffi.client_ffi.UniffiModelEntry +import uniffi.client_ffi.UniffiProviderProfileInfo import uniffi.client_ffi.UniffiOptionChoice import uniffi.client_ffi.UniffiQuickPrompt import uniffi.client_ffi.UniffiSessionCommands @@ -72,19 +73,43 @@ internal object DesignFixtures { modes = listOf(UniffiOptionChoice("default", "Ask first", null), UniffiOptionChoice("acceptEdits", "Accept edits", null), UniffiOptionChoice("plan", "Plan", null)), efforts = listOf(UniffiOptionChoice("low", "Low", null), UniffiOptionChoice("high", "High", null)), defaultMode = "default", defaultEffort = null, - supportsModels = true, supportsUsage = true, supportsProviders = true, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, + supportsModels = true, supportsUsage = true, supportsProviders = true, supportsProviderModels = false, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, credentials = listOf(UniffiCredentialStatus("oauth", "Claude token", present = true, fromEnv = false, valid = true)), ) val opencode = UniffiAgent( id = "opencode", displayName = "OpenCode", modes = listOf(UniffiOptionChoice("build", "Build", null), UniffiOptionChoice("plan", "Plan", null)), efforts = emptyList(), defaultMode = "build", defaultEffort = null, - supportsModels = true, supportsUsage = false, supportsProviders = false, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, + supportsModels = true, supportsUsage = false, supportsProviders = false, supportsProviderModels = true, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, credentials = emptyList(), ) private const val OFFICIAL = "claude-plugins-official" + /** A gateway at home and OpenRouter for OpenCode, their models read from + * each; and one saved before providers named their agent. */ + val providerProfiles = listOf( + UniffiProviderProfileInfo( + id = "home-gateway", agent = "opencode", label = "Home gateway", baseUrl = "http://192.168.1.2:3458", + models = listOf( + UniffiModelEntry("OpenCode Go/deepseek-v4.1-flash", null, "Home gateway"), + UniffiModelEntry("Z.ai/glm-5.3-flash", null, "Home gateway"), + UniffiModelEntry("qwen3.8-coder", null, "Home gateway"), + ), + modelsFromProvider = true, defaultModel = "OpenCode Go/deepseek-v4.1-flash", hasToken = true, + ), + UniffiProviderProfileInfo( + id = "openrouter", agent = "opencode", label = "OpenRouter", baseUrl = "https://openrouter.ai/api", + models = (1..42).map { UniffiModelEntry("vendor/model-$it", null, "OpenRouter") }, + modelsFromProvider = true, defaultModel = null, hasToken = false, + ), + UniffiProviderProfileInfo( + id = "kimi-k3", agent = "", label = "Kimi K3", baseUrl = "https://api.moonshot.ai/anthropic", + models = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Kimi K3")), + modelsFromProvider = false, defaultModel = "kimi-k3", hasToken = true, + ), + ) + /** Claude Code's plugins: two installed (one off), two marketplaces, an * install under way, and a change that failed. */ val claudePlugins = UniffiAgentPlugins( @@ -170,7 +195,7 @@ internal object DesignFixtures { "opus", null, )), - providerProfiles = emptyList(), plugins = listOf(claudePlugins, opencodePlugins), mcp = listOf(claudeMcp), + providerProfiles = providerProfiles, plugins = listOf(claudePlugins, opencodePlugins), mcp = listOf(claudeMcp), directAdvertised = listOf("wss://192.168.1.20:7447"), directPinned = true, directEndpoints = listOf("wss://workstation.tail1234.ts.net:7447"), directUp = "wss://192.168.1.20:7447", npub = "npub1q8zy7gyw0l9fh2qkj6x4wlcw5h6xyq9d0k3e8w2yv3m5l6n7p8r9s0tuvw", diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index 1d710ba9..1ae96244 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -28,6 +28,8 @@ import com.codedeck.plus.ui.screens.NewSessionBody import com.codedeck.plus.ui.screens.NotificationsPage import com.codedeck.plus.ui.screens.PairingBody import com.codedeck.plus.ui.screens.PluginsContent +import com.codedeck.plus.ui.screens.ProviderEditor +import com.codedeck.plus.ui.screens.ProvidersContent import com.codedeck.plus.ui.screens.UploadsPage import com.codedeck.plus.ui.transcript.DisplayEntry import com.codedeck.plus.ui.screens.AccountPage @@ -175,7 +177,7 @@ private val pages: Map Unit> = linkedMapOf( "settings_machine" to { MachineSettingsContent( machine = workstation, connectedRelays = setOf("wss://relay.example.org"), credentialsStatus = null, - providerProfileStatus = null, now = NOW, dispatch = {}, onBack = {}, onOpenPlugins = {}, onOpenMcp = {}, + providerProfileStatus = null, now = NOW, dispatch = {}, onBack = {}, onOpenProviders = {}, onOpenPlugins = {}, onOpenMcp = {}, ) }, "plugins" to { PluginsContent(workstation, "claude-code", dispatch = {}, onBack = {}) }, @@ -197,6 +199,12 @@ private val pages: Map Unit> = linkedMapOf( androidx.compose.foundation.layout.Box(Modifier.background(Tokens.SurfaceRaised)) { SessionMcpList(DesignFixtures.sessionMcp) { _, _ -> } } }, "plugins_opencode" to { PluginsContent(workstation, "opencode", dispatch = {}, onBack = {}) }, + "providers_opencode" to { ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}) }, + "providers_claude_empty" to { ProvidersContent(workstation, "claude-code", status = null, dispatch = {}, onBack = {}) }, + "providers_edit" to { + ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}, startEditing = ProviderEditor.Existing("home-gateway"), validBaseUrl = { true }) + }, + "providers_add" to { ProvidersContent(workstation, "claude-code", status = null, dispatch = {}, onBack = {}, startEditing = ProviderEditor.New, validBaseUrl = { true }) }, "settings_appearance" to { AppearancePage(settings, {}, {}) }, "settings_notifications" to { NotificationsPage(settings, {}, {}) }, "settings_connection" to { ConnectionPage(settings, serviceForeground = true, dispatch = {}, onBack = {}) }, @@ -281,6 +289,10 @@ class DesignSnapshotTest { @Test fun mcp() = paparazzi.page("mcp") @Test fun mcp_add() = paparazzi.page("mcp_add") @Test fun mcp_import() = paparazzi.page("mcp_import") + @Test fun providers_opencode() = paparazzi.page("providers_opencode") + @Test fun providers_claude_empty() = paparazzi.page("providers_claude_empty") + @Test fun providers_edit() = paparazzi.page("providers_edit") + @Test fun providers_add() = paparazzi.page("providers_add") @Test fun session_mcp() = paparazzi.page("session_mcp") @Test fun settings_appearance() = paparazzi.page("settings_appearance") @Test fun settings_notifications() = paparazzi.page("settings_notifications") diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_add.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_add.png new file mode 100644 index 0000000000000000000000000000000000000000..47a7988885826b1af0a53c650ddf26e83248dfa0 GIT binary patch literal 48436 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¨U*u zlp&&(M8#E(yl~NfYj+3@w~ss)_FKE>_|r@SCwQplvPBNcYGJve!H;1h-Fuyes87oh zcTb^t@LEc=gNp$b)DhXo{avWwLB4f4@Dqn5_=w?g*LPak5L$0)1#wWKa4>_mFGvHb zh5WWz19c}VVgfPe6ZT6-i>TKKRe;kQ;PDE@t5kf7-aJ1%&+4@Cp`ryDP|cXcRx#2r zLM6|;;1RSgWx1x0r}1Pf8tl5?6McC9(yZE6g+y-KDv>1fAjI<*^$~Dsd$(|y82byd zHE6g%gxuW;c}lvF7o$iI_=W_@6am952`%rBa?o%Dw&(W-d)~4xtHSFtF-+soSB&-k z59o8HoH)7>=M}=>#1PaYYV7UK&d@4FHBaucF1>x10^B{h)3XmJ&sk?mYHL000E6;r zFf6;vXsMM6Xk>H(sf3r!^9RGYnBC8mYC_jksSS2Re=7MhudY3W(5k=sh)~Fek!Z(G z)o|Ku-Cg2{-8Lvr#&t23*#Hhoj$;`v0Eyfk65OD5yHt_?B!pK862AU@dB)^@i&UkF zdX?Y&RZ$AAx`9l;<|}S}CTCU%EQ~f5W);!-w^b~rBo&nNunrbA{B|dzd#~}V6Dw|DV#FeM@J!m!1HYnR!$fc87AAlhvlzncQ~CU) z-oknf6nBp`O(o7y^cM1jXB}#yc=qf~7K)?H=lgo~R8(_S|MQ38Rb7ZZ@dfkk{9NqS zoN|fSol~i|jYZ+Y04|f*iHcNdSRngtXttefylm6aMWj(4}l3dEr(~3+mY)9jl%qjGF;>`(c{l)N}$6`&=g7`%* z7Ss2A?hrBbM4Nd;dc4e0D$#1<-Oyw`q36^*VF*umbXl~~x)zdndLTmiG(J!*n&t19 zeP{xK!S9A8xR9B|cGl3H^_iwN$>ZTuJ5~q@pLdc_r(X5q%4u?EGVDvLvXLh{WGR?t z$6wKmgEXx0@Y~&o>#xL~cjGf$az4$ITsCm&x?8ZWZ&8%tFW{OmN8C^l3bBvTS`+c? z=v!7|z4-cxv-ic7U7U>2Tr&GVDG5sY(u_P#?iD27k(+S~gZAiTAZ^+iwqaqEv;O6P>^N)G$8bqx- zrSS^C6QfTlllCjgZz?(7>`Xi74koA1Y9F5c!mJqFg}#JV&B_cmLBkozFoh%IbT%ej zmCqJC+H8#z=igX?$b1!1tMq8WTuo`sM)`< zmGbS1-3+c5<`NF0G!iHYyofGOEAr!A`fR#alvBVjV{hd$KN|xL-#KAm_XyAn)yj&D zXb%dc3x^4!V5=Rg(Xh~Gg1XNTpYw_LRN{3(06lUSDr$j_NU*Zz1mJ<50O{=kPQO^v zs|+)YVyKAooi*0yxi1=Bucl$s-T^jOhb`+9 zzO94gS+1b{z9?E<|1K`UDyyyLkk06G%8~c?S!h1-B6sZ{59hvYdRB8_^G$pA<+-D_ zJ+ivq4nz1aB(&dS)PLO z0Y>*Hl<)`YWY#VxyCA%&m<^pLbbyJ3*SrSsS?)Fw)lISq8VWhpNP zXAU9fZ*eXSZRGrt8+ubhFqf1^*8YgPN4tbxE!@SCnCN!Fn*sh@u8waTiuixCQgX{p zkB!@CQ-j`!j*5^{^~rfq4*vNXUctF zqOLy_q$IIqed2{nH8B)^7m{UmLZS;eLG_R5ZSuN_iJpg7zrsb{OS^42R1r6|gp9y6 zEqsd4RQUIBhltpsUo^#IyU1v9!N@Q%-ZZF`Ry8??l3G2Ufa@_+kN6DMGz02M^^wu3@RXJ>;96U8@-?m&|*H{DFWo z^PZ{A4R?NVZ;rSU-eLAVB$(ld?YXpOpw`RM!VCmXK;FcSMML+dvuZ7s>Jd8m`n^cW z&U5vi=OIt1oXN^%_mnG)MPFWOTtY-s5UN*IAJ&Q!oUf72+Xpcn$@;EqzE+WcpAR#A zXuI!m%Rv7soClKA^J`_dxROP|N0L<{NVP&fpYdNEt4)=KHac-zU`P*L{r45W9$v0q z#<7w@L4$s>J{_Cdvtg?P@r)h&zXZpl{M&=Io~vFcnLoN8V$fwfZ(%z;l^JewQ_W@S zk~9Z;hppTzLY{6;l1SLvy?Pu)5*$S^+bV$<5*RCt^}jy=O!NQyjsKHd;-ui-91J)L z(YOB_+m?|6y~qn6b#m5$hwU&I8a=@K66Du-47@4#bpGw)Df{Zsan}#HN~02JMA|8+ zGUNH$oO7t)@ze@e40V*`ZnP(6op~e-D4W!K1WiB-E9>jY z`5)r{koNblQt^5KAblu%33R=ihUHW9Kwg+T=G8yII|KB#gM#8JO+sfiNd{A6TC9&W z2WEPh^28>O)GxzX=AEhg`SYT~P9yy*cm#P-xyu&tKZ3cG=upe_Dgl`r52hbvw2?w&>gl zJ9W=fA}9tpWP?t8beZ;x7wLG_#=zUJ<=ZME_hz%x`sWsCB@p7!Vbn7ipZj;+Q(SLDlhc zQ*``JQpCBG_uA`@Tt~VEudC=15QNlUKu3}|MZY@V-dVQu6KlBcr;NvOqzh~6*N|)L zKzA^F33HjzfmPf>?$mxim~GU1T^$y4FlHe0(uvSLP*Pu6&|&y7n>J8tG{n1uHN}=J zK(u`ThIJSgBwgIcCVMF~l)V^BQ96dWcWv1klwRH%bDu7g4EcSX^&KEuIBZ1(wZ&x8 ze0{d3A8rLF-#+9Xdqit~6J_1^-m)zW8TiPV9nwpi$wuZxcml832e@iclh-QmSh=^X z;}*YJz1yb(tsz=-l_X{>fT5H=N_! zI;5Z}jA*v$LX7mC8q}#E$`FF%pQMo2^tZaN4T$ADfvqXBn zGLQN3`$Kunoyp-jbsbr!Z0sxZHQeKZjBhHZ&4kf}G#0#JC=Z*aXksP|FbvMDo_|$O zT^eM?Tl%D;aNL42pWZ8-*tZO*pY8qv3cxMas;-{!A~3H)>3^ozt~MDCVAO^{JR`Ic(S(*oIINV zm13t6M?QIu2Z>1w0W`}>_R0?v2W~R@F5o;dc@B&c^6VZ6)&g4ru?XEc`O8BQ05rvD zu&D*2^V;l6U8ekwx5dkY<0#A31BMRQzmg>`M9bdGe60J$ZDyqgum6Dgc5_-Q{A_&{ z*&1Tajl&Tz9r{FHAyF#zz=*@X00#&Fsmo4Ia;4JcT0vLDw=1ut9Zn*4(GFc#?`KGMq2rxrp z^%mJ{*D{*yEFrU+fk?Y#{#beY#V*H>YSTTJhwrL-VXXeE0O@vR6g0F@L&io*#kzFL zDS3*yO}h`a1uom}ad zAm8G?d!|hNF-HexvpcS=!Y)Y1`zZ+e;g&#meXeZ6N60-D?miov*Bw0W1^1U)^jtif z1z%9}CK7cju$b5Wq9u#QL#FPVGc9(v+gun{nAzNwHT@w7A6#vImQ?-jb?wn$|=4Tu{S11FR{hOj>!}8-Ha?6vr6yw z62$4n!BEj6Q`mYYTFOFG9+u^Gs353=@pfJq^a9gyYJGLuYP%<^k615Gp1;35Nu(3+ zBjg{FqPL7E=OrVY$dzR>kr#UXtz7DKvkUpvj^6om&OBo_tUk35#yZTIi;awUT;=p} zu=j52@}Jut^XG>wI;>(Y6HaJcjeDWP`BV~Zzytr8yhoy0TpDgK1zN&)GhRg(c-p5V zpL^1Yx90H)cNz=1fi(7VgOHK&$F=12g(4(2hqyWKMC`*7m|VlUBJl%D;xpNkHBgmFbJ33k)n?oTRaVkbCA0K~lKTP0q+sNK|A=N1ng_%cc*qyo%uyRnS zO4TRrG2_SO^nW~Nh48(G%|CejfLyHg)r-aNn;0k-hC$fqHNnT&eB2yoerQ4KLkNH< zvLBSr?dAvuRM@<|{Ew-S=4K{5SIQ%1kV+ADboz|6>Zt1Y&Jvw(Ovs3~>ZXu-6fc>` z)r<;!Nv{>;t`NYl8cm^69~TuU4+!!hDAs61_98}8{oUVQb_?b{C#UnHU`Z;gd}8W% zjceu{q_;Db#FA_MnO=s%e^~(i4HqSLEcX3AZr%_SKNPY?BF#O#{PG{#r}uvKICy31D)~|Tc=Z>n z5^e^h#dD?(G;)Jv!%pR0xn)tc!zy5LpCIt9Lr{3DXM)wjzfc{2AMSt@`T_|1O7srz zr|)r8?i|@mZm6lqBaBG;UjIy)^4Z>l$;&Zgrdd ztotHNJNMHdC@9FI%lf1d2-FIZVXvM78;SeTwBh%V3Y35Yt)xOCoWxpvLBJ;ZnKAOg ziJbMn6aU4xDS-~XB4H#Cci0|egVW!##)>0!MMESWOT-|`868B;W_YsaG5C}$%u9** z&EyE2 ziT(LwZdVC+nCw}2{*=?*xqrWuzcmh02_(fBx+Jlay(q3ojHxAHTe2t&J}y=Q&P(a6 zOvZyT-{s_wbR+n$;cjmjt|9>OFADcA8c}=Dtk>mtqju702Mib`V7b~Gd{;C>cMDKF z@2;vxKPLTXFjL1_C$L28>*wK`jMt=RiGHXwQd%hz^YnP|fXg^iPz{5@dGjD(E;nxM z-%?I5uH>t;Z{F+oKC%@(KwzctN5UYp_7%}1!$1x1#kUQ0u0WRPxGAd1S=@89r)FW^ z`uZA9yfwCI^_7PO4`;&0PqdL$7`LH%Z>Yd8-5JMnk*O~f13Jx?dPX=Dx2y;PnalEaaY(~ z(Z0f5X9GH%>Q=_EcX-WTIh2lv4R>x%RxbkxFV!hb#U&&;{i1cM)O(9p$uJYX2%2%i zeUq8&o(C%bXcL4RDoJz;hFE}NxMU;|^aw8fbn_)2ZhZ`I&e(|o&t;-eq1}g8Xh)L3 zkklAY%HADod7$ABauDx79qKzd1!97-mZfNCHi(2qyh)HW6G{*YsX6XVNwa&4k;Va& zn9@*u@W(@?Y}n(Z_LhZ;yt5yr2dkA?g@a2njv}wV^Apa693oD!tse;_ZoN2&Lgedm zej!6NhJt%8k%-}b+^VN6b>geg6Czsg*XdGG{oh>AF|(ky{`1!ES=3i0HTMqMyRjAx zs(g1+ZsqYS2(aLp_2kE>B;HqH9i~|^YaR+~=rF#ac~yt@7%YR5s-X8*^TMV_qWphi z1>Ok*t@klAn%X3ot{`OHeaYR{$1y$eG}l%ZgEwp1-gxo3pr#Q5UdfEx-3gVWxRV9% z%P1G4(i0R}OI%ih8=eM<;~>8)fKZDm!YB3k2fWUNCKFUwi5PAjRkT$mZJ#*r-%EZVNV%q*4SP=@VU|lK^&nfaJNi>p zI=dmU;wuhD!;kLN2WF#U5RbDrQ7xT&N!i^eHKMGS@$}nUug`GW*rgaX0*uY1d6q2- z?s$;i=C(0N!r0e632OfIv(k~5$CkDsDvu1$x(p#~D?Ld59CSvFYrZ;r_Zx|#fAE^IGfI)82z|s$m{RFL#Nc9+ic3y$q(vUev!WIIXBc#P zkeOj*xf9U7h5?HX7CRk(_bnY!h?O7QlR`npfuI}KlnVG0r54)Sd-?`de zFxk*7&$nseB|WzdV$6#5m{Ny!tyX!lKqPFJ6wj$G@f-XNKkk(aAa51%7PRb)yQHg^ zyi-DPeLgVg9|{NEG;I;#RWAnCK)Q1L{QSkGB<_MF)FouuB6j9we;Eq*-T74N38k*B zb`{%Bj;Lc}a9&&C<>e<+nE+x{isCM_`B3&&-8sSChHN(^|EO4=j*{FORZ;j6%lV0W zPcm(%;$jW=S=1K==Hxk0#;2(SUs(y#^Bf?i2n@SO2(|}ny3;)De~dglyw!8py2p_v zCd=1gPs{cZPoz+eptVz~t$tbdz7eydG`rF@-&kaJ zHkujoLw|+05!IB*Nv?Iy_lu-zczc9tNg=i;N#IP!Qn{nY??!opV(1w%R;*z5nTQvZ zMNPm=*OD@uS&%nb>*_(a@%8T8Dl-kEZaa()A_HaRXg2$qh zm-HFk^*Kl)mTQ16-h}q)h#p;;86wE%AdTVX7?|-EO4rh+yANtpYL*QqI3Dw?ry0BVi(92djVsP&MJgsE4v{zXzB?KT%4Cr%od3<n#2gG{5sutj zyc1IM4sSb-C9`Y~OEW%R09D`$3s^*3e~7Y1`Fk-%Gq8p!uI>oR^w%GRxWDxm=Ve0R zM@N|XxncgW7w`QWAzR9De#P;cII;iQ;%;yp(YHpcU<8>p(SwKwk*4=XxId$X$}Ger zcs#o$G~>PfaZ@vBj-N}gz{_$4hdfpz;nog?*zE-oWfIci#|uG=e;%=TmC0QCAHZ$H z4~8!N)OjAHt`dt7{NtT=q6t4>VeV)aU`-y^mKPr6Ra%H4R&zbP*R`NNd|zGnBU_cn z_xwa*Drad|N=fCF;mi_Z%PE=XW}cT2Axa){Ug6bX**l3DKUF@Sk5FwRuXpKe`bC6u z+S-qtMorgzrY)2vfC4m2AIR#q+?uTX7S_!RK>Y{}u{=AUCqn8x2jDx5dO zm@N_H`Lk0x3kN-XWQu*>OnIFrr!s`^#t(I7GY0l%M*^iMzcRfTT_mA3t+lvIEpBU& zSLn$SF{0OFAh~txJ+-jua^OY^O4d{AV@@lHkCjYkMDB)M<)f_}8P0M2>tO=Jl7Kh^-n+a+&PQ6?k1M6f-Yv-=T_;g*$3PK zm}~xe=v?>pVoaVf>1|`jhbY~<1UCTeV=wZ%*MBDDT1Pax0(U6tV`B|zpWZ;U4y-V4 zbU0_;%@9XN?CU`8WcA-}M*QqxD(RIiN2oMi*4)5tN#O0k@ggweKgn{!x4!p3FSfs; z<&Kxqa1{)K0|LI?HM#+OX=zSOeeFe~LflD771i$;c2RQQ|8nyg(cgO5Dz#Dnz~dJ3 zO1%qw^lyLv#0R}kij?4Ek*Zj5%8>IPB4{~TXvcM8bisd;=wW|7+zS`73k%h;G+g`# z)@qYFO5`_acD|gwG7P-8rLV?XFgNNyV@w+LhW6!uKd6L(hF#m7U<4>uFybsE^x^ql zZ2x`6dMK)-%*FNU8(v}qJP3-_yxSq}a*oH6@n$!0`{pDK`{NXRPprEu(7dapPlou& z39)|0HlDXgar1E&h7-LDN3x40zXKLXr0MLdzYKrdupk7DZv$ zAi3(z=Wesuta=<#Hzy^MK5VWlWKoR?$o7Mv9ZX6pa%!LM-yCd-DL(c!k|{VFesPcS zU|{QMx!-I68mEKNh{6;f9Fo!s>!S->0|V28ciR3uFAmrta^Ln=-nmawrh_Y3@m`_$ z+0AC*_u!CgL|zakp^niHzc3&LHz~v7)nqOF>@d23^kzSrNpdHLck~e8&wUR$+b*#O z%Y=^3S9hApD9k$QxfmgtHwXHRHsJg9!o9ti6O{`auY*bXVM03F6O_0`%~;*%(mxzH z6H{-E;jKCiHy0t-Vt#ekFZtdD6PzSu!Yue7u_z{*#sZ`m-)qyN#Cx4hg2m6pr6c$- zvVtoL9OJI7D&4NliO2=*)u2E&3)xBA%64JMJf4G<@ZjFrUw65atpu*-&ZLB0Y5EbN zsq~7Pn*W%LtcSZsCwv!F@E^rHd+s19T-8h4RAv&&3UBQKv9=rFO1Rp1si=Gq6I@T?3cXjmlkjnhz3DkHAmy zKU2RMcJGe^DnlG(ON%>>J{1SiJjb(v0lM*4=SK|l+klP$^3e8{JrrHSQ7)B*fBRsr z>|F3}dt+DN?2mP6GESXuwLo~QM5b`FM=)-Ppk**;hwD4QzhO}hII(bl-{S+Vi6CYC zV9?^r<=OojF(A+8wqLbidTMBR!?;W~0aoQr^A&h%8m&83X>&uaqF(Q4Wr+@M1LNK$ zn+$!Rv7H8n1ZKrL<}RBLS?KOYMX7KY^=^8hWjQm!s35Ek^YJN-)Ngjy*)B|y^VR?O z<7J^*l2@r=xnG@oh~s#%Zr-m=1|2}|vqC`Ef(L-vG?4x^u774{+OB=D<~+eL`s^z^ zL^Gq+-F33wqbb`Q8%qgkCV#qA@ZmR<3_N%^$hRW)tu$?9oI@W!++IOjy5yMy5pYKU zoNcSAtX}A?K+9qE2W6##_Boi~%f>z0J6C9*cIugZ)=B?VT^=j)4a?w+1VCP_|yz#y~qlOmZs6<`I&kBWF=IbBE#}0( zd*_38KI8AAz$^H6+|-)}fraD3bITtks}}Z1g#gmMDd$Rg$cXd}5lCzGqH#2SE{2Iw z!&EW-&6|cOf{cdlIHR|0xD5S3zdHle%1yvZp&rm#FG0tJ-&iqPM-H=`Vd(*kf;@rA zgg0me&+yR^{r_e zm2mF%_xFt3RE>#b3c|=DfPF6!19nkpxVct&;5H@YHr=)Ku=hJKjF<+!*Iw-GFZ!GT z>QWb2`uW-9Hz0a7y$+^bTN}}x2Kip2>RljPK9yM-dvgw0tiQBJTljjd^4ExUGl*?j zYppRI-z>xu7^GPSC9AML@&Q)!QG-q}!8lTagyvIOS+yLXe+H()H<=%T?vwt{Y`w@T z=(39U#{RM{8Pv8|R;0PItZW)Ix=d_MRG~p*$QWQV#Ye6Jw}BfBp5(SpieK$n_8x|l zbIHH|?0UU8Z*q=JHC{)P!ei%0rq>NQFq0zn^0%Z=w)^SFQHwc3Y|Bz(jhQ$D*kfgdQ!m6g~FmNW2vhbanC2nJluv9zCnL)o!XPdLlQpe7GK`YFjTpuHFR_ z3^vWN?-D*zefYa_ZlC#s*Ms$vPfYJ+%lPNu(o-fW85uxbx*aud%K<4CPQ}f=BUuo_ z>~v7KdGICJ1{GfyczOZckXF8*$fKDVAc@2(U-VvMo<5QXHu<*Wx;awr!UI5(om%$Z zna)|AEHSOBTXAPL_`7-iOSV%N-_Daj(>k}mZnGCZrtkymf%DRg7m)J%UeC6~kRJca zrL=iqH<;zVQ-kxsiAtl(>A`a7D|(P}7Q; z;F9Z~T1{0-x7lX*apUC$^_NQ$WVsqk-(#|S65iR48{hZaq^7u69T%DIR`C!5+O(hh0)PJH zE30J{e%!jEd3gI0sKCnlLgoCQhaDuT{ovvGycsN$@r=>??l4D1w+8HZyCoj+z6tc5 z2{u$;`i^~MIC5DD>{$hqJe*_QEI=Ino~id?a|mep^1TL`)bHYfn{>9j;`z(rB8!uE2pn{# zxVs)o9Y&q6VArZHo{QB-j#Q{)E@uXJ?|kF- z(08F!{PvOgFTjPwX(;L0{V81@EH%7O^RHA?UVx7I9cq)US86-@3P0()a%6-tBBP|* zDy-CO1#CcTaHC_Wl1rXdfXe`ekex=oD}x)lIO8{`or(ac;*x@yg;;o0A;-RgZHHPc z!u#|pG8hl(24t%?YHlMU{KacD)i%>Ne0EAQz)8Q7*1G=s?9=CWV^B9-pn(&e!9YX3 ze4>MA7~bl88?#~bN`eA$`)<}3L7dR=N7))2qhxsad!slDt+c8r)`H8S`hKMH^WL zf9q4nVfp9qp^2Rj3wDM;tMiFcAgvw*2je${I9aGO2<8F|j%%KMFoIqqlf)xS%EwUn z9~qF2x|tQ0hyu@!@D5|urUW;;pCV3Th-&1Grygkvw&2^cF2Nws(WiOd9C)1D7p~H# z)ETObRj)Gx^PkY+AXRL{d=E<+e2;!B^ar1P@>jdBWJr=Axse-3r~eg5CQoaxN{;Rh*`Zsx)wjrdF>~%s*qBy62^I9*+m{;NF`YhGO+> z&S`jaKvT&F#O@-Q7nksy`~|wdd5{Wu*^w`^K3^vA1Kde3sd>^9^0==XF*nVrtPn|Z zgEJZ6F5nH9NCpZP`4Zdfjt#ntT3yR13j>23Z=R)ekrc!*viet22$-{T_a%w2BG4kG zrKZI-VP{dPsiRAQL3+@9MjXM65p}objy3{zxb;VVlF63g-)aN20bc_K#$VU$WP`V!1#hB{qoCon=b)7r zukPIws@q8T-}1T*ZC3TDfJ*{4Bd|=;_0h%|xf{zOR1d@Df?{OCfkj(dS&e1Yb01-| zA3Sje1#@t8QN9|vhsW+zq{f1IIWz8cTJ{>GZE?!Vt1v=cq zz!){!Nc#~BBCP^3tb40T1;ABB;eAkUPDn6>?H{R&$v4S@fgC`@R|2M3U8br;;?FuZ)$eR?hLD9!8DZF+N`6gt61{iw@`|~D#8^wP6`S0z*zTwO=mjI6M z`z?-(LnL>xMVQS?B-^Ta3%f6NSen#{QJuhlRp%+I4k@ zXJRB`RBK00LhG;26UzAe)`3Lcu{(BFQCkEImrasTUNR&X50A$0^jkz^rLxQa5+$Ez z7yAA9X?uc8nRi;FuAGdkPQKshzo+UM#3FN}$PnAze0&$yIf1n~<`!f>UUA7GF#5#0 ze{fFsb8iyad;>|L)+%i25i0^qQ8nmYrtZye24Eshq?OQ@Y_;!|I@RH9OQvIbof1gU z|7-d)oE)bsl>GL$+gDZ}Pq@>9P;Eezc^b{I*vTXJjNPx#zv7l_N2We`-zj->#u27# zP5CWT<$V~jgZ|oX!OP2czOsw}2-OFgHrKrKLq+PD_snFR)E^a%Z+nGMdnOGP`*&85 znInrSCx-gDGfBjBw2P7+E>C$Zx>Tu9l04XEe@G$QB(*hpnDLbkBzbLcllfPM$l*Xz zQ6fQkz&Ok3j?H)K=%u*U8ppBU+r-Y+PHg^bcs>k=#Wgk4xm(MOoZk*9lwyVy5Gmq% zEV$f%{KkAPAI5zrS!P*Od3Tvf$=I9gx4it}DwXe>yqN$$zeXT`k9k_6^% zup<*wnL7&_*fFXeuj_S{6$CF&Auvy2$g}^oj&)dI#!}M8`9v@(eldH;@oor?v`T$y z+ReBU&TNbGNp!;{$eNFvu(l~Rm4M(eShnA+c6*J#DsEx5bt^H%)fxkE*V;>(vV z3X1gq;F_;kS9tPKZ5{3lZ8uN{(21gMR;(7U)?bjxMCchBE`RI#hcQ8YUU#n) zST9YFX|riRThZ8Q6%K}e zM$PYwc`bgHE?Zj*7>PDD@n%hRQzY&z;YWY-JI&#p3G9x7H2BA3qC&dSVjiLejuu^E zPk#M!-I~zR)&W4GU3@rJMg}ucl#O)5;;C$c;JgF@r>}2Rp;Uc%yQ?{T?svs|-BQY! zi`Me8>nJwq`VPDtCeqJ1%oL~QyxQ<2&T>7M5nQ-E<`XMK4qwm0;zJ)dsIAOW@|cOY z`pp-Lh9DkqN4qHw-7))9uP5zO_3jHhn{Vu&e@k2vlTS6Z=!xx7;@JJl&aO?H;PCkw z0>_K@DLFXJ`6dN5y%dS-)a50doY__}`*!IEAnn2Y_Map7GB=DF)Y8Vpoq|p0<3Z@No!TgTd=~jI_48%G~%X%}vrs ztJiR&I*KD;G6z4t`jV(>Xf%*(f^W?XE70gw9{CaZ)tOg%=KWHcPk6TW)<*0qd#}my zXOQ=4TYMKzb$#BIh5#kqpc1bX)y=_XLJ)}*dRv`EL;RIy>no}(q(w-Ssv~OwnKmS4 zej4|J|LPdY3E63V=N_-#4+Uo^KHJm>$(uXYNuj;{{Eq~CnkvlN8U|IT`sSMDmAcym zHobwueP6t+ts~a}UvWyVN2$Hzbb5B&Paft#`kH)llC&scw$vdg6XUnnrYnK;MGE9mm5@AuaB*h9n)mxlc|HTA)7RU~frL&C@;^Ykei%#SAW z_lOyt((8zo4L`V@>_R}2+b>}MZ0@!_eOdvu+h>_RiX_52C@YF-10ScPhqOQQ_j+zK zg&uHNz_%F+|W z4{EuEoX#G@XDW#>~#Nk6GCk8j(NbKCaSCiGG*TSmbG_Ae*=pLb zj}5_7BzaPIm9R-daYP|D<3}|7| zMkd#dV)QTm2?!+g4(M~~!$sKI{AP4*fsr3-DA7Sn>3?`8FsB4R0HykQ`@PZ`%juh#*OnJvlHMVcZiQ>4UNk?LCHA-OvYzwU8T*yIUmc_jl>+Ti@1SYMw2mo;IRO3 z-5I~u0|qvd>pl6a4z@D`X+eE-uj5;qk@HxFh{_A@3Ya?LD@6 z=Q<{5%>N1Sc|;XT{|>U$v+l^G9H*Ws5kdCGWqaxHde530JUPY`oi1y)8l@_agjgsh z>@X|o?=5>9!M!{$pU`>*)`#bBsjy}NH4NVM2-9WNFAZQ~EomZ8a_{({1)$f^N}%m; zuL#}=oD;#?Z@U_2YLLG^X#3(Eav&b^x7K}b9lpp3AI%-KzZi(U_6s>aE}It$x&js8 z%*~~0U;;Pvr>gr8Q*aUeRF*-RS_a;0kL#Lgx}tXAN0U^-x#7$(=yO<1w%>~{k19@9 z2#x;fADI8EeBfgsFN8Lj&V4=-Z`)O8`hizk`nSQ=u8YxWq1V>cB1Q0MZk{TuMDcSc zBcoF$yM|Q02*%tDg+_KoPae5?nb^&T8Gx1XcntS_=A9@Iiy&Zox-pJfi5x24LHprhu$k&9UBJL6YdKL$a zG~a);I^9noNiVr4WrmmThLv1(cURcIBcX_|r#xDd6TC(NDj9mj;6 z%<)O>%U|_eZvi^v{R|B#i)uvDnqbn<4J#_ANA_QodT6NPH+BUR^o-Z~hY~a;PNIC@ z8vqbTu(_r5Y#m3aUP|Z@1Sch_pDCNhJVfECzuR7a>qUDiMYQ0se8#OU78PckjLAyjykz~Z#MlAmD>P5wvtwWpJs$C zr)c2pM9U;Ag%MsXTVAFA^0Q5A2SZ0f*TqKVRR)aidUiGt%$02NHxJU=oOUF!vr%2T zC-_NEUK{_@jT@z_%EB_-{MA<9MZve0sml7Y*Fatpoqo_V;qiyu%~N+T>*`JG?tFlO ze4U^o$5~M_!hh<$zkz&{#c7X@~TPni_u!$r0FK8C$PaZ``T zeUR-gCEL+RrtmEj#YIvNqbSfj+@vG)>NrCgw6{90BM&46!uV2vFbxhyive}m#V!{< zy~j!0KJT?!1XwSVtfvAy9{G~GlS&hPTA4iimtQE4;m1w#&U2tQ>+Q{WmmOGH2O5>Z95KMEWGPD z?F2pISLVw~hg1)uDmHV@`UomU_t;UUB8xS(zAJ0T7`b$~43CrU1f{|`+{5GW57m7| z)@Ux$5f^nFd+vEyA$ceC&#Z>|_l~HzJZe%!zMjQy&D16Z_*x9SYpPT6+46FOJNCw0 z$orOeXCySbiX=f-GqET8Onf;hpu~)29neYA_KUrI_U%gCBps%L0+RRD2rd_lCV!=~qbB&S$u1>%h+5ZXK+w6_b$x>*Afw zg@x=t%Xo;3RH#tVZv>eQbzZC`cfiO}SB|=#C%y_qVSmVCLn1&g?B1`r>#Lo{5O5D^ z#_{aVg1LBtML#o9I!YmhC}ocvr=5Ox*Egk;$G79&k)F+)h$pjl~CbI}RT8m{8PXa%iss1)30%B%9=o zN}d{%2QHbh4(N$QkYtenGuA0B`Vsu^mv~xUcTz5#R2}E7Ou*;!n?i!^4*6rbpew?_ zEA+(=^vBDq9#H!|_`G+yb8UHZGeZk;RK5oOT#tnLYAD>A$b?YZR%@qD#iVXU6kw?h zJy|nMpZ>IK;h~pcDucBBVxAg!nTR_|NJ17%$%21WfwO;J=ljBllY@YR2?>Mt>YsM+ zjpz`<+Yj5N@$G%;o@(w@&vJ9q;R~|h){KwqkPofdL8#kIBSR)Fp6yN6-1979L%#jR zXJQFbMSiH`%m>}^<$L_HuU&V+Es7J^RjdFnWzGJVp zoZBTAdkv2E{I*gq_`(Y9wk92)&d(4R7QZSMO+~g|op$3@T>Q5EbNdPWuQkg`;YX1P zAHCBY$)oAmCjB3$%F#UAx;_EFKIu^uQ2x|;O!&;#uFREXnc+i>s$BdEbkl47%6ddZ zg45>3+;mT2HS`QpktkJFJ(-I8o%q@1dfrtgB=m!31}CyB4Y)K=j#ajvO^u4&%;M{x zKNoCiWaI4xwjEn0sPD4b0Ez(z>x;qDCJ`J(bx8;0+z_?#y?5DEzn;8?OH9Yo>5ozo z!ws{y(myaIykw`lTYqu1F2>pB^&#hU{x5CEDrMW;xSX!k??|@Q&6v@M&Ou2kot-Sw zU%k#oc8QJe-Txk%6CwXLkU-h=OV3T-ts9LLSoHNOJ^RxHooWqu?7dULQftb_=&Ha$ zo;ten@}0x)-p6_W7je?Yh%g`_Jq)2VBi%3v2m)e}()G|S(#_Bv(xHe! zcbBxZbTf2!Gv|xX|NQn@Yp?y{taJ8S|Gj*{a)6ne`~KGF`dsn$froIm1Z=?;5WZ(z zk2k*`jS7HHx!#G57H>mhC-3&kBi4_evwpZSn-hh4EkjP?2=TJIp)#|WM<4(B^DXRZ z_08pF&`rqL1FtBb4yX4ReL5w^mJ8R~H%T!RJ7$yRb*YI}=A&t`vt4sS4aChmhL>Xz zSC!yoT?>MUoknP{AefnVzdSvC*q5ih@tTbOscPniFZ6et+h2Iwr(d^YM@x_Xu$CZL zRcvHfHF>aA;7~EF_rDTwo|>b`5X|q26&Q2|g4*u{3o<*{$-t|5&w=CU4H9v`=AISp z73_n@=#$PxTZFH`8RX1FFiZ1aVQnTwzTX*let5Yi^i24n)KErc$HNy}3-_3LU%=%4 zQcO%wO%X0d)D=B^ZPh81^dfie;brM;rA&)%MREYDK2Xs5F^9+%YTJ1zzw6+%{iott zwspZ$dtapUdxkW;f6XGFYJ1eXmTir2L0|p|LS$!r?N|C-3ij}y>u%pZ1M3oi;!BnM zjOf^z?t0j_VU`zG8w}~pq0bgI`ZbWLap3wa*Nks3ocym`H`B}szh~QOlHj1`4e$En zV0K3LCixVZq3Y@dK~ZSZGH;!t%uUnH?~6L`yM(cV=MbkADh%Of4HNm{Gkl!znd1R$ zCw&#y!Pv+6f8TJvXldunUjYhv>mZn)eKqSu9He6SOp4<&ku+C%AHMkO;N#fw_@&s- zDObh_XS|<(VY~iA7WQJDMMK6HhFKr1nz@zM^p&D@26MuFY(&MErWYg{T;J5(fF1Lz z;Yv`pu+nD3h{q$!T2Vs@Bju0kB;FucoJ;MFWW7hHEV>tmL_MB z_-$Tq!1P~8<9`sw|CM*=|3D`H{f+<0)bl?t{5Sc@J#zWhQnQk2VAH3yGjo;mR9O(n zgQq|)(iG5WF-Cj@eS5JRZ$B%bF=AKs1ZgTOpsAO&=Pp$Uq|y~YE&J}}%g<-5kO6@k zGfJS5HXm(3X9#&#{P@xAxHy{w)@Eq`dM$qYXC*e`7Y^ZrmLecHF-BE<N<)e)C-YYqekDxdf1au{Xn!m7K>X4V4)xW0c6@n|kp2Cgc<}lLv*Gz~5bBd< zU4N$j)T9?o4OdIcR9}1qnwbcg5{9O^^G%+v@0{BMYp7f_pq{M8e3aJeP4}Gov-k`c zSN5Ln-M55GV&Nd9oELcGtylu6OlfqpQA zOegf)w||}r$Uu33(Rf-IaFi<3i3-Zk$jlsIPm&5ce~$kC?Vo9K5tscbaCXFu3VP1~ zs7!n{Dzj~2lUblsU&G~YY3ZIN36j-~N)6i6R)>@eWOnLK@+fU$F@Q-IQ5dymYJ+E2 ztl3LV&6=8(Kw6K7b%%o+XReaw8|yy%c`blrk%c5qo?=Tk@=X;!dDfj!AtbHxpS1jSY4he}hj3PUUdbK-lGF$yQg{?u7spG@Zx?|pnVlv-Q znd8a!RsOaayga|dCVm4fB*3{X_#g=sWhgw8w9t@J-Po<9ts8_%-eRMKMci zTkHN7;96;ZCs!02($2}PF?Z#pN4>M_`1nt$HSK;9Djs9xhx(3Twa3+RG`+T5{#sv} zXwh{>jTvlVuF2qGTzc8{5ADg*b5zX2UYSvMxam~o`o?w*F7c)Mz7@o}v0azVtd=eq)q*@o5XB^8P2 zNY;-CohXASlDiV411=5?vDX0i=!r8jNZ%{AoZQw*XHyE}P*Jwb!OC@?ZuQ};Wq=rr;_*%TeLER{f<@mR|du+u^{wGuQh%o>#;s zkqSSD0D8cmKdo-hOP-(juO)b5x;%HAWV4-TI^T-PcXxYV;W73^Ne$4gz!*Z=5~LU%Sb#pej-n54Nh#chmYUwNXUa)S?NQ!at!1Mw1Vw9ml` zt^GcXldkg`WOaG!pDB_Q$37sS6`ObP-`hRk*de*Zs(MJAyk>s3~PMC0&2OH?4g^4 znFU(HKWg;=(?9`=#90Ds4u#1Fb^YhbFL@}NY@EoDiF}p;-|_So#!FVJ$MJ-ubxKQ1 zWx&*}EMF|WcOd`1Q>Vg^-{CAkf^?ABkGAl8RK+!bPeTW&Jtg&EellW7l+AKHvKqBX}M`?5EOp-oQK1 z$$)OB-H8>4o5pym_F^GOwSQNqL}>x6e<{0W_$Z8j2D#mUCdy3@<;^1O5DJwOJ(=oO zVmcI!ic>>J2wy!&5MlQTA<$!{8%Ic_yX_0UQ{H@*cdQHS7=wJV7fm9chA3rZvo%2k z*?Dn^fqaXFj)-2pF*56QkJz{G?I%J=-3t8f zt=ev>Me+QV+1cmy2%4AZoK+3w=%CV|+jbw$miS*K{CdJnw3r1JgbA68E_QY%cIymg zv#2|$2#<%)sA)K(J>SVA^7cV$+Is}F@){M>7jH?Xz*@R7Ri$6-guZ%EW2g2BUmg=y zAkeptC;QKHcEMN%qQl1n_kujX_W8&mj3G@KxTAvHPsliG)eLi7Ll9JgsWArrh0E7JV&V`c%)O2-dqoR4elqHbGR4CN0qI(}Qs0zCY>PVPc%<)lYnJ`<)4W97+#SrKDSYpE|0BK}iB=NB( z4UT36eGOZUzXC#=k!V%iNQ$6)1MP>n3QS}aJlp-qhGvjas{9ViirybE>ku9Yw&USe z?Vr*TX$jOFT6Vt(Cz(=qZ#LpV;hNDm$HhCx`XdtSjK*biBw-8HcF^X+hc{9Vjw&r* znDH$5RNlT_VW|sz7Rad-12A?lCKGI$kM#8RW_mrA3g?}8zA9#39A#^}`wlMB9E(A}{`I(PL z%4Rz&S>1!oHt30tPHS+#usU&@pcZmT(f=SW6o;A?|NZg#dWM=hPI*6ybr_^+AKwnPD9#oU)XYD7S(>o}Vqg+-I&Q(=c0my- zrLIumpmv0P1;3JPkiL3c_6wfpwyw;hqk4s`^gG%ygGjpCJQ>EzH6psRezIMY?gTu= z(dy^EggJ358{%i!_16c^d2$F<4ta2opLg*?q8OEfSM03^TkmA);#TN^P%Rk<{n32xs zrk?6iwJ(>b-}uGyx5C6o0Fi|?C-s7#hz)J#EEheT0L~N>q`EJkoOFl{X>#Io%G|Xc z>`4(jkS7#=E%!B*+dyey-l&L3dyE~{yki||{zKd(KLnxDE)bDhc`eXUcwJ9y?Bc(9 zs;X3}N=~#g+pH}cqitGSBZui8^u9p5CiBNNLpm2M0zc2hISQwPc-CK#wC)A85U=QZ zM^2=cQ%j?KDS}H?f;N*fbVZeD)DG6{q$c^c#LAuwtb(hg5btI^MY(@Ucev}#gfY|( zm`mjo2a6-B#9wPM5c$Q9Pqd#*VdLsePR6m)k{HUu6%5{Pq<6JhkHtT$d2ZxyFHZE( z*!{B|3)gB-09beRmtQRiJYaZy#(_D$J3ORwkRxW{%_ajKTsqKONBYD8DK$O=)>v6T zHJAnM_(z~`)km8|aGJc)bA9DkeyUrLUS;9_9mCRi@(BSsgP@hxiv)SZr|ZbiN)3M9 z|HOeOxUmByhsY(k0*aNlo{fTt83})GkCXCCcm87ycA#MmyxJELnJCYJXAoC{-L)ou zw3p3&8K8Y{S{@oHdK@UP{P^N#;h2_tSKzT~fq~m!f2~sm*$`g@+FPKD_MS}Ab7VsM z97k4acR3l2r%ILvolholu9EAHmk;`>-6-{YV9kke&HLO=FsY(~WE;omOzAyb;z>TH z;saXG%dE-rSOj!TQz^$+n*ml7g$y0I|KLZ{9M__rCm%-exPVd!@7w6}=l5x_aFh~^ z0#)0>TQojA8+j#)s{kQT?$VSC?k{ucq}qkHo=${s`IegjN`V6oF*%_>It0 z3aF=u!?&Lf7I|EKxvbx_ah3!VwDFcI_C#O|-Go;O0!v;#^M_~2afntBPLxVpd$ULxkc-J6$f`y^(gmJwc9fEzDGMB%5D))w)v4@U*PT+#>x4N1;%*V z8b(ZymS~u;_6O!?a?n%Pt%Jm+lYC%(t;Ga=5~}1iS?5Tc%?HIhrQ#2&i=+>~ z^UEWCpf^&qkB-E9Q>)&_rkHh8MAFLf8Kim`{2)8oI?zNY2_Jn>gF=GB$jBMJ2JRZoezJs#;|3I4bZ*vN{zOL5mkr4$bC; zxcj+xmTz%%`O7x!0WAefhQ4ff{BJ+z#qimb(wdSiXRqy68o9P3u^|k$jTRVq&UxfW zx>|LXbZp`!jF-(^Io?6?l}0}gA~X3`A_YRfSGl}dSo1z)DAC-|Ss$#@%$^Zc#8QQp?D{^pvCOG z#f92z$slK_>j5z?Yo4b-z0H$VS_3>nZaGRFaHNW&rB)oefSb_RxXZM9aVzLZX?%!J z_iY+M4W1u7i%NU-;E*;t>1Zn0@Jd{#^sUbG>tw#9%Y-izv<^cR;`3`iwI9<$o2{ec zKR;jkLM4RSK<^>0b`*bDIKjN+IQ)EyVoFQ95?h#J*GqDiM1t-vi68VFE&rZPfWgFW;8hEq$U%SOYl)S->$t=Fgup zZI0|O#%>O~b49B_gfsd^$vX4fH>Qb_6$i+aib^W{Y6i_le zqjf)gQ>y=~%gPcPvAn-jL4BKb6q_&*d>;IK&bQAVv+yxS6(t!EO8kmLpRLR;U6}m& z=0srtvbPzvNZ=d@=hC0e zdZHC_V|Y3M<&m33zBQg*I3=@19ypnI{mwu@&zX^lLXH8+nL4$n`UPE2k%&T=Bm(t| zOlQXlGC-?>i%b-CP213vd-LMO%3wR_a;Js@xaSR)I=JUp;Fp2_*i+|iAA%P3^kh-@ zx7^4K{E_9PHGqk5P1IdTlsfNZns!Dn^%B7i(!|`~mfEk(n4+&40IcJ>E(Aa6ORnhf z-%=OXsd4g6_RjT>1V#UXu?L9cvFWoiGgDwBiWN2K6?w;ZL}%?I%3=`G{; zAb|g482b!*iVlAGGrz9Gs>$bwy5w{Nzau^0r z-S#C(7wWyo!Jt9`b2N@|kV#+KN!K;f-sgvAApG|RJ<+iFV%twaipx1e!iqB!tDh+= z0kEth+vtmMCl%)H%y2-(XZ>*P`#@~HYe8)N#aZbeDi$Mfru4dCpLIW-PuQ+1{ruVJ zjU=^gZ3hld+|z6VdwnU$ZK64fSUtxo^tJ30CZdj1zW(jw<0F@Lg2y=);$|e#O=VzQ zGoGI=7VH;G(fkETKAp^>MP@6s+Fbx3HN_D1XCOD-gBa*Jg{=g?s6uXg0|g@46dXLR zH}OHc_-W2Z>#~0bFL0|4M-*y$sUM#19dSr`EzS#-m>35LK&bw_QWo6^y#yQAy_6j= z*;8MA+-+*tI;G7Hvp+?}6xb{6;U!?}o)l=5jl_8qf8sSyvsdiN>LkOF$22OL^eLtV zj5)1wxR!R}(IR0lP{Yj?76c;t>&I-}HjTdz<0Sz_qj~;&F);Wd$y2VtI-#8ke5&HV zFUD+ZxLySK6eE}ELQK-CULR@&bvw^qO>HSkT4^x|-FkdF=gWC7sQV~7Xj`q=~gMb1E;j_X!60P)230;A*PD*}D6?MOhP}3$-g%Pv zl6DFMsY9nQN8=Ro2G$fKYZfRGF*9G)I34`$EYSiE%L;&9tAZq)BZG*UgTgPfOh+*Z zQYmV7hUqkhkEW^ugPl6(*Z?^&4n!KP4KABr9E-Z#(fhD~#8Xld7`>K)(mhueJNseZ zacIf1q6&i!Ga5>vLj0Z`j^;}nuGz2OpO<4G5=m~jKf*7D)W!9eBWxw0E;UMk5pI3F z*gCNnB9Z+^u5z>YXYXv}w_Q}Dm>gQ0lVu?!k^)Xxlvjg*dic9CBFPU0q+obs6AS4d`#>iRDc(b0+uQ9&x= z{b9;tRc{fMw+y{IV%cS3Q8;OidkYFKgqFMVn8+V1ed5dOc#aS{m|yqwMDOxy9u-I8Kv*oV(#8!Tbljr~ z(65vO1Sqm)b?_nk;$KHAW|>BDu~7-Hf)TIZMf3@dm2iBM;(=mnWcl?JcfAFQ2f|>2E9Bl?s?rQsop`X4S*Y8tLZgBvA#JeNkDDL zwG7_Ls)_Z6j9;-9)GmbzV5OBgln=j=g3^3k#XWI{u%W=%yVH)KPP*dbQ|gt2daT8} zN3R3In@Rm=xzP&8?=eNx0 z0-Ny#kRd&l(k2piMsiTKZ;g6H&mMFuX%y6_rQlEz)Ub41u*gE?HgobVrWG|F;TFU} ziH6Bc)??^xt}hp5U|B!elL~*q7A|NQiELxP3|PASY9iKKsJem1EwY4y)1ErcC?-t`$X72mIY zh^BH`%1wS7xUX{1TMcM%`q1K`>@=%AL>fL%`7>VffK=e4aS0II7lm+1fK_#6)hfke zP>={lxqv@zb#Fkx4wZ93t#mtgjaKL((94D#7<@O3k0GGHH=MLY>SR8rB~U@{H08g7 z*iFNsBJ3r=b^+))e@)lsmpFAmbPAyw9UYR3KK)rs(SU7n}LBpVje zJ_gQToPj?RK{GGbTwFn8FF);felG|qV-HHR2@?v)wN^EmiF_nR+}?ZJlypfV?eO+! zH?=5%WaUnM;Iy4zgC(gbEZ6sE({(Y{mM)cqirO8?{LAY>f8i^=3hkRqn#388bzfFksbH$=i^gR_03;z;JP1ZhWj5^1jI+*8s!T)Z2$8Nsd zy#Dv=zg&d>$2b0`7yi5dFggBKj5j(WFf?!e3%0NSs*wH< z`pMQ?D#7vtnbPgqH^s`wtuoeuk)OgBKRC+Dj$TSj8$7kra$GpM={y4irU*q&JH zbZ?*TmE>~(ecblIO1~A#$L(YF3wHMJTFIajqIaSd7L}jN`Z~1pbuTt8R zq`!;OIG3Oa7trdG+;nmWsvVPq>uF=)90FGLo|k)g?r-~Wi63cG*Q2i!2EgR#n%^A9 zb}b_SC7|F!F{`ZpOpw^+ZpdnOM13%rW<#q@*t8+bJxMw?oT!Cy6CJywTG06t+gSnU zuhu_H;Pj%E?&N$1yf~3GALgP~Q=P?}>?FkiDar|SoM%#|+D_z>L7w>L#NpAXI1)6p zvC1Uf431M))7iD-+jrQtA6;3@XRQu~CcW%FJvZ%fjMs@7LZbE0f>&n?v?;Zd8zhwE zZiaqGKoZa%<%y)zN`b!@$ zRf@YQcwq}dyTS_b0@&$(X!E1>)oDTdY}`?zur&e*H{ZK9;vr+iET(1By$*z?tFieF20bd#y4jmtYrKPZ`aTf2HkUxHkB3VA9&NpFX{hdb~19 zuC>`;!47j>^W8BxE5yafmc-!g3j+5B2BJA&mk1zt3F!63a=huQmR3{k7QfZuoW}!w zWbDD$4adi;6;(SPwYqw*r+?LN5kQ)vJ2*+%os2GHJ}WGB{M~5xfs3xFLEEP3OszkM zNoI<#{xcy^Nt%xQs7k^YSHOAoMKOfNb(7*;~*XA8k%6A!3ezZId6?0Pd^k(T9I zri-{}yxhJU{%I!~=i$*J|M2&E4=JD_WvldT3sui^p^?9-KeVdS-R#)kfj0Be2hg{j zIsx$Mo2MRb?#(=JUbWkstfXmb(6&WHYzR26hbjVhN)-;C{O`$JEWiQMqc%5JV7oo{ zMISNJAcl#V6MNaC1j>xQ%ey#jT`dJ|TiI6UtLM;2p={xmq&TYDUl|^L*0B9`#k1T( z{V6=R;l<n*{P$o;A<3I5>O%}MRz;y3@Sy^?4i0b9CSqFATdV&lw*MQ!IkV3(p3kC?+b z3tu9P`2`LsF|NH;cTD6K=p3`DJ$mn=kR3R)FO~E>dyxPg_$~O>a2FsQRV=pz@5#e_ zIy!*~PMrDV$yv4AZqv#@xn)fkq!~1v^U;sV)S0GdN6OpWmB$g5n?m7N{$w1&sYNRQ zxk(rRB#&U6hiGvS1C%Q)ZhOXkGJT=~MM@kGr#rP)pS{W@BYY&+tqUcxVp~RC>l6gx zHi~Ok|Db!s&B&Z!ZI|Q47$L4XM03?(K7wdauj2X0B`eWx2qPl4+piTeu{vz{sj=ko z;2L&v5&}1oF%aJN?zj}M+VD(cfXCj>oJWhZsi}Q^AXt3vS|xOyJmu^eUz6ba^`b4d z=q8fEHxj{RAnvqTYQIW;1V}N#G#;n&{F5lLl&0alRDyaY{);MywLh13x0HYav;ZZ4 zTS@4r2TCk5!^C_W@E{gf%SQ8su!o1AGU|va{pvC}!7^+cS=IS4|L1b^(|Dnv+q%_B zA?&42xMc>;HVT#P9Ei@mIMb;4Pa&K?nW*}03tyIuqd)igU#>$rYO)FU-Po9 zu8%3jxn*~Rd)u%BCE=-EcOKO}Tj+ESyQ5xvbsV@Y1agBWcFw|N-V7F6s4MPPia&ll zdWch(@3r;=d=v zm3^zHKVK~8JGpAm7$?073?Y+kFw;)z+faKmO1HusgqXsC`HWL}+W&^)-jX%hSOoLF zI{~E5(wGd}DguT9YKPf=Hl+~-ne3`Oxjbv1sw2eF3@F?=*%a(2$~-me-OG0h_uCo$ zF&?R7JXUV$OoW`P0`eV4zd*+D(k1+Ol}cLGNZw(fSy4(2wigyVox`g@=@x_afms-5 zjI4Lw$se&f<4)&^ZVS`YcD=RKKN5)mFq@DvU66xKYO(Evmgi2LvO@2aJdAh0C0hAy z)z~1(Qc0`K-LWZ>;x=lysfnU=oKOe}KDkn=#i-cXEmMrBz`Qqm zJXnYo@!sj~BOMNn*vet(zayUT$?_wD_K{*+LOe2#u(v-<9ZO!Y`YvH?yYF|JOp7iH zB>jO)w-%OMWW5$ukLQBS7yNt`F6fd)FIF|T10a&&y&C6WxmK_sq{=^gSI}1CSl;}e z6R9hRIbKMjYj+ET1N8K@S~5#B!tR)KvQ0uc^}=sya!Nz?24r=dw1jn3$fzpVU&cYP zf#~YDEXL*M;6VktSNnbejDa@Tp?`>rmv*{tqxerxN)CylampDeDHyCwOWWjyN2oMkGI0%F7kZ~x3v8%vjria5TuVtsv5pTX{As?a^!FjOt*85%om;L%$j3x?T zVAAr^^#ZE`HYaSCQvNepr)ppFu$7ohM5`w-NnUO99@5kzWz&wec~y4RG|_*-)D5!N zh!J5N9p!&o!vN8f4^8H`cII(C9#5Da#4EAWv@7)e^n{)Wl|r5*1UMbz(-bJrv4hCG zSRq!9!9U;UR+E5a=4C@`N*s1epjx;reU8-IiHff!8Lgs2o6K zPM(h;)>F7IFZ?(X=?*5GS~FdH%g6~>GE-N9e7w_|7&OFpP4?Jc0iFo3Px~!i@Va4h ztSA>oU-(r@LkaVvm)0#$;=01}7~MxR)(X_9%3Vs$>rBh}?eIth;W+f30a0oxpZd5j z5>Y!?glHb$o%h!Sa4+7Mn50H*>FOT$U~TW;b>5WZY^PU_|@78xj8h3^5! zB_l1Am>vn(ZmCHK70wVup$Nc1BYpMzd>wL*@S@V5Ru%ppiAJc8Jg+wS6&$C;0np5< z$+8wJO8d*m$jCywjCX7$2~!*-(AT8M!mV$B=TSx2#HIbhocN3uV=Hmft_FVno#4)H zI;ZCLwYVtr13AnOM$11;Unn`rCyo^1gQhVZ16n~AC>mW3UUlo5n+QT|9R>}ZuV#O| zCr(X#%2@Ve&mfMN9z1&%i1)nv#?KF=p|>B54yvc~%jyYY!s>{E+iiE#Fg@#LjavY4 z73qv$y8`7v$7Lmn^nLG+j7v=;ei=85^fowZ85YO{j*|@HCHL*2#nCuanf@p8SobS< z?AkElS~w&*j9~}~{mt>(ohHtfXhnC3B;?yxtXk~{b?=^@@khuEB{Ggyh1&_74R7ce zsJ<{ZKSGix!$->TG52s&pE$BRcNt#xD4el8?94O6Ekb0|#YRHnA6@25U5?)#GDbOD z-ZVzE&8=q>mBSfl+c;tErwEfb-J3t^izZxdBm8T`;_T! z4`$(5>`_scCh@G-*+t7?p4~&Q6&tyzsw7CfzoZJVr9qz7c_DuEgzb4w7hOtHx0KL7 zvR?{)rTQ5(Ww>-2e}(o&kJ|VknCURd^`ajx{!9|L#f@2h$~|RU=~`gV7l~2MsnN`c zHPba)Y$Q9=c>~O|CaW`IQ%Wk@_$>t?9w7_WMcUaGE!65WEE+=!*tD!E&a{E#?T-Bf zTS%m96B|`vhdMz#Tyi^5<~6v&QE;F*auZeyruS*gi&#c^TS}(HCKsvC;O5-NmYeP8 z(0sP*Y(j@UIT7UBmbX}WvLhZHgYFB4uvWFpb%Bym>kL)elUmdNC#hausPKEFZdvtb z+(|Lq@%bu*dJt5t*DvZhm7rx2#3K4ia071&1*Szvc!`!$vb zHwe#2wGBNyxZq^@)XA;=?SPIWA%3|_v{t8cjQv%)fxf}vEFZtD@^VMNnxMEOOn(l9 zZQMtr0EwvV8ruvT~9UHYO@EqzA6E>zc!V7s?x z7|E?k|GB^u7Y?NuBZ>(yI%YEc~1#}vX$6DXT(HhiYY^N}vS<9(Pb~JUM zsdm-=OEDMnEEB^Ig|))I2b3PE41`19&4ZOPQH7 zCBI@AgHM)w9Qz6nsIS|pBn#1tmscxdV^MQwWYjh6bgX9#gXZluMhUY{C>a%kZpMbK zu_x!tn}Wg5z@Y)-=*=7|?J18}36$0>DrpYbKXKlp;6`~RvuLLe^ zSknaGY@3w-v$VLX9?`eYfiH)DQu5;I;~u5c)UP-%Z?E5>!xNzML_F8@0L;j}LR>w)QyW)X+T8{elKZUHeIX=Vjx4-p9QO8!Nd=A=fceb?7H6h3HDl zH-~|O?Vft)p>DB_@k}0{7^q^ezzn zC>wUbYd}Nx%Xekvx>#JO)EV1}!x2R(HAUf6q)}Mf1)YifPP!axx{!v4Ou0vj!?c~j znK#R@A8uIEHuFcRng!>~*Kt1?x@APNA$FlDlvcY2ah;O7YhFf?pEhhp2qVs5ED5XM zxvASN4}JvLcAiKos%_6*J<@2tX&h7hix>Xyg$2o1VGCA&MLM8_{z?UWk`w+F;T#en z8y`GSgV?rHJ6AsotqLFA{tNP{Q0&&6>Rj%6F%kibOouRX;qvFHPi{cII!s{jK_)Ju zE8ogqf$tpl_1m|Xxqtn8IBz$E0FwdmZrp|)grh;9KJBglwaH#(oCB^Mr}MWJpc3xx z1^_+q6?$`nj()@iCLPww7pFALLt9ADEpaNV89KmczE0! z`Z9Z){1m1SI?MwAHOgN9XG!MfnxvF5JVR!Gp$R@fZh!l3K0OgC^w&`PBk8H;rIq#R z$$NSt2HBZ;wW!{Zo6q%26Qk8HKB(~xK;Tmgy{>tBgV<&!I2p}3W8X*K%9fq|Y5Lci z`cRkTj(AAJ7W?dsIm`$1!5Hw!!*ndaxf}=hZo%pJ`{!RbwITB>&tAXz^R4Btd2+Is z&aZ~!!SddEdLpWZ&JkjQ^uels9l1qUq--iluFJ^6tG0z9dYnr>2tp^KsK1xA!9aLA z6cEt1x||$%!sor}4k1d{r{FXn9vM-<{d;{OjS&mVuC@v$voSc$?9Q|91?C@KyhtUv zL7NGmU|O%Yg707GQe^MQ`K3}zl0TEx|Lb#PR0u+C>YTN#({Kko<@_u!sD#TWQu99t z{rwYxH1n&y2QgaEVYRRxM(C~pWrKRF=)O)V{cNak^VV`oSs-UkN)}V!LPwVF92;3k zg{w67iAyiKn~R!1U*q0i*COgh&IQ`NnA2{t9`998SaVRTWkpyJ5alcrb3BZ@xk9VM zRg7!G2DNwm+$+r1JIa_@=t!KF7yQ$)Ix}-3NP#s~!yCxFGK!1CQy`mNXO8wFx$pX= z_>V{fbpU%M6YCl*l91JVBiEL+!832x#R6_mqy1c+XX2N1*M;dawCDUtoX*xtTVAC^ zFJ5}g9^S_(;7M_j?^FNt&w8pSTqiJMV+ECojtR06UZr*pb;4}+>menR5=Ff%EXgh} zZ%UnEyctec#P@rF`Spj{yBnm#4G+Ka$4{d_YlZaM=uPw{`C{eNEs@ZaCB z{{h+m|N9~G*2=Xn$ywFa0jo2v?Xk7ir@NZjKv0tfCeiA#P$-SN^9|ne3D8y?F18q~ zPQ|K#A9eQmIc~nSRcSd$>U#R^=g)|?uvU>{7_wRI+bPe zNf5%pl5QIEB2@^kQTI{$#nn(a(Da%q(RC_}lQ%n0)d*`XAl znhg$HxnGYJy#c6}Z1&Yfge;I}g>P4_%uv3sB4gLsHsndf`Ge{l$~xMpcvdl^@sVxH zuG@y+355H3wrt$qmm!rQDW+||IHSLa{9ss&*1Qb$eXXF|#K^5{WqP{z-kX||f9M=JZBdM)v%4u>7 zFpg3X5}TtxCl}h>;x9m;%*G}SAGmw-{Dzp8bb39BwhxaA zMo&sU|sFcOJP6iAH?T*CaFE5hUjF07yV9@3gkc5@UYVeIg5O7GpebeAC z$=P0PA7zea$ht?&^z_+{<7gGZV%s0o#DEK}Wh$R#d<+GAm;$~mu($+l7dr3Xu$@@3 z)u$YWe#`V#ucUG3Z#hrd=9*LX8H9~6KOe4n#pi6Y{VwHEpUnGVBIzU&_PlT3Y>y22 zurd{6#4CZbu9oQ62S@izR-Qq(D-9g3$2-pRYq~}$c}B(rI+sXPunl{5C@Gn1)b+_l zrQbVGJ4VD@O;v^5Gkm3VT@=p8w9Zof8y-dlo z*f-juGiA2JUtiuGjk?QIR8No)W>t5@mzyAx%yYSC&XeO_Y5;6b!b#XPM-~o;Xcg#J zBjV%#4D2nQ2-e9ciLegK@5A~T(&T6Ticu6Ic%t-VnC{(D&sS&jUYR(AZmA2pIw?!d zjQ-|W3HH!cq{H68+k9=Ce&5k9Ge0BWN@`i2U`a1|4ST}NEjmrlU7wG#${^WxSA=tz z-frZJGUsRmJcR&a*7)}2HrV3!ohm9lHM@^*#+&|f56II~`P8408s~UjyygN z<6zd^nPKSF9Tx>Kl>MmNA|lXwwx07JbIoM_4sybj-Zas`>_6W}g;i}%cOT30-6JO7 zu{+tF+L}D6wmU^HERd5%qiK=`NGF=y5v&9>B1spn3In&A=?1aP`5ocyis5%H zLyb!4o%?Q|^suo}?cYAjww{>eulkDHVB~e~-ZQr>WEk|B4Q0RDFE7g`Wx!?Bg+EGw z>Zt1osy@34=U%39Fqo}3Q1ZB>6^vtm>?wLg#N4fxPJK)}JyocuDd|B#kjyi1cRhk& z9>H$sb*a9>5$mb|ag?kc2a@7*VLM~LQ8o(#PQeG0IUyzA#^ z^Xl~7Yn~l;*+;FN)l0|@w1aGQ0qxK4=gGO|6kSM#M#pDu-6Jb#dCjceFP(QYt8^}& zm`aM@3FWJyjZ&`%X=fDlhs3dtl1AO3Wqn&(Rln$g3J*VPyMwVWq_PCGi0 z<^6q;pH=rWt<5mp%Z-0yy?fw5(i}-*X~Z^}vFSF1UYLsdN*RoF63ay&Mtmh+qFQiF1*FbbxwJIVaM zVR!UNR3Kf=V|q9*p$3_IbEeL|1Q=~-3Xwu*tXCs)n0-=fr=oqvgsQZ$V`0h@@M$CPEU0<4FVYRh6_w?NQO4gIYcWWa2_C4GOFds?Dr#KXeQ!fa-B+ZM* zOU-iO%N~ZX$Ljbi-c6qyeo&L!7@7t|d-y8;3J|rzfO~Yc2l>@-aD5pbnot-gC%~%s zLwO@MvFS+Mh{Q%|g>R1A-D-ohH(9%o0y>w$7xHMFP;$(eV|7|CbCd^)c4M`lY7c%^ zLHeU0i>&9vj4aM(d~)?PRH0rg$?BB!loxB3T8KIQ_s zc2yW+NUuYv>!$C{A>E=@d%T+K8Zp)V7{c!*Q8*5RZ8&JviVv4FuL#|P+uyr)a_cXW z=Z1-VurfWM)9fhu@`Xb@9O*Z=N|!;8Y>W!0c=e`uN3pMnyNJrVH^LI-t8^MA!9Wy> zYZ0_I0UDOtb-$TzZ_O>^Y>A#&BI|D}LRzul`3pUPP%5lHv3HHKw35xFnW9H3a9nJsv2em^jhbzAl$9{&D{KOS z>@%i~@jnn?3^zo4oGVOBIvWi`oCZXdFC-@624b#rapM;h{G9}EXc3rQ3>7f^HhlUE zm&@bqr+AUB@%`KTN69M`P2EyZn@6FK+oa`0f-vbeRNh6>!mVQ}qHyB5T2^z!J!+zf zo75U-{E;6^$M>70M6+gWG0hEcBRi@~FnD_4EgcU~G+En|=}#%ima$Ygx3g)lxZB3Q zSSgY`mxTt@n4EY6)8O&NYWK2T5o@;a?}pj$&+Fsk=sk%YkELP`B+|{u zvcV#Wl{Cg8Wn8T9Z+vg9&*-RMJ~LG-MEiJN5X_PDR{G{wcjp(MLcH}d@jE0{A!J9p zElu1=fo;3{(NGeBCk-5h_WAI|=jNOkp{`u0YwO1$O*j2)pL#^EfG57_vyS+u%}G1J z9ib*gO}>@xsm&S^xe;97GH{UU9~FksHEmY$6g<1=#+(|>%jT~!$+++km4--8MyEx^ zs&&Gv63Q>!n?!r2*sAqmP<)eI`ta=(JGdf)I^qiKNt=hGBa!sN^AL@R)1!-o;~_E4 z%uswfj`A?yR_#i&upXKhh_$OhBkB~Ubx5u6Yu{Z2kLA<4NXD?S1 zyv4G_oM?igaEw>g(>iMyj` z2UBs*u`Jjdnk?7>{^2HcJQ#TLm<{bg+^*&Q@jDXSy(};+QsyP!q#Q=(JA$*jw(h#qj(R2xUBRkUoE5&jU?ys@I}&_N$iB(} z@%DI$-c9nr{DV??jMN>6h90WV&Q&7k-adz_VM#oTZ-78UnP5v{lIP@$wyH~p2^evu z4!*UQtI7~0*HzD80|3`j5RGgv|M7Iv==k)mN~xrum^ut{mb;$>zbB8`7P8R;MG9qP zct`<@U#x#LNUL|qKQPG)0nM2yu~JKOaCnu|Ffe02R(Y zv(}?-Pm7lu`{n7Hzoj)k>8cCb4Pz&(@E|0%uIPIZPk327-7>5F(h|?;u*f6+B=s}Olv(Bg>wFh7EEL$F*erypXeB^1yhzp4N z<#y9L&u(sE>hn*wntl(bOSD3t%VVC=409{0p^^^0(>3Gc5dE|Qm%H`PduzSDP8Z`F z=ySlL!Mw2A8XOHIw_sr>pMy1rviLUYxbn#8V);Xo4+>%Yp7+xw{q6+7nx#C+i3uNR zLmIi($)!EZTjzpfk^)cpcQLQuFomlC5}|MN4piK+=*s^_y4hH zx$c=9IdTV6#}xUlK3nqG!!JWv&d-0*9a}+eM7ak%6eiEZDz$6jU=(e)-ly+yICl_2 z?Y*i#qtO3V*;hwJ_4WOt)X)qF3=P9jN`rJWAPs^)0qF+m?#@vfq(cx)LPA1Hx>Kb) zB&9p==6Rm?y=&e3uDjOdkD0}sGyCjw=InjG`}_TbY?i-u?{=Q=1pqhWk6+D@ZW6Qp zAlTwVz2$bHIeJ4+*7Bn==Et#SS-MQ;N)?#R5TWJrjRB#QYi|VVEBbQKmsp>_aAm)i zue$*gX@>AUAH@*ueO7V>czX2Nsmw{aeBh{wR@J9Rh|slS5oiMEh0x3m-RbT+sQPWD{Ax~dLd}2`WANG8A123P{;#|=Y{He)qEPhT8SDXN z`h6V?q&uzaiwiED`*E)r<*mhd!sL~b)G3)_)P2|<=4;FD+m|Di0^SXvz2jnIS3t*B z3~(57CMS`hb%t1w{8W$O_`8?W+m2Ejh9 zOai;@;lNE)sjMvTm3X`haV{y8y6q=dVLW<^?I?HmT(+f-Vz>xwYBi5Hc;+K@pX;Ke zuSK_)VBm65xA)a(&6t!hWM`3z?|maNv5Fvv*NJp(NAh=BE$>tj%bQioBTkhF^R75I zUqxe{_nbXLiWt{`Od~sW z*`C<=58&Bx2E?Nm(U+4hity?wVTel4!npl40y1h%*OF2#TM-J|Jk^95dqEeNjYNMR zt!jCtKKhzA5er<9yg1aH9x@o{UDS|}v@}*_`CeS(v9oZq861$K*5!H5(l_$I!i0Vt zpyQHNh-G^>IGv|ci}w>NcH*3Rc?~|-e}xq#)%V17(yO2Usc@=P>ntvyP=2!>V)UZt zXN^Xy96UcvOHq=o@A`(}pNJNQmvHUq|Nrq^~6{yl*v!x9ztT^7ry^g8vTLVmyryAz8EHA6WA0J(0F zQGo=OVdWMqFOMh7m!(T@e&W&KCO*tgYp!p}#;xd;h1XJO;pi=iz&f1&+`HFmQ^+Y1 zaqy&538U5G+Z_~*N(nnu=E!Wx(``hZas_!VNXUR~$|8QzB3#fkMgh~m-Q)iGRBQ|W zg}+sgulmZD>B-cf<41%~*Rq#&;1x+l*T<;K(~Th6t^G^WC4|z-vz;?1ym&dPHd^+o zMBj7uB&3{QWWer|`?1KkS;8(sS4nupe?%q)|9aQkZ!lddX<8*ES5Kp6-kmj&QFqn9 zmffhjXHB^i`Oxbt6=KFc`$T(p&rrUb>VQt6o1v>4w-uIzTv^(ZFfW|Jr=7LZA+pGu zhdQ(v6|fRtn9&cX)S!10WtpzUg;%hTH&5_y`zWG$OIk0VJ$((<>j&3%IQ8q%&4;Rp z0u3)~d^da&ORf*zY_eWCkKSJDg>AL$BCeIlChmgkrC9sco$PCA$Wau>TDv zzRSqofr|hA{(nk{{ny4;0@N8n*fK35zJ+KIx*&efs1u{Nk>*QHi;PkO=~8L^&JO?i z#Oq3|m-L5CWODjmUVsjY##&u{J}02ACekDk5CH6`Qn3S} z4L=%|4`=6P-Npyzef zchY@SiQlWa`+kiJ0b~v7F9$LSb}T&`oQb;hH;)$#F}Vi{;@gDijEUIun;Kdsng?;- zbiWcxaqV?5H@|S1CZ;m-?&>3yhlk5x&#CuJ6J87bCQ#_l7U^nq7?t|%_%<1`IW$e} zlVwsNlk>-@sGpj`$z5U%Q?;QZ<)%~baOu;WHxA{6px4fQFAjdELW5f83?||eUmq!4 zL6{Q86B%=3=k1g}YW=J%o%$LRzMa+@u2Ufrm$e)(kA@JR7bY5izEe2vKXiBh^2;{k z=#hDf5dQ7@lN?d(I^*U*l@WbC(6xJ*R@P7AvMG-ig{?yRsgQ6-RcH6dgO)c}%#9ZY z+5L-cA7<@pu2)It*y?P5sy|`bCddo}n1#_)E(e#t))i*v@mm}jG{VmCCm%B}AEQ8Z z7je(;eqHy$3ty)4#p&Q(kP)6a6JOipAQ@4Mc|$St;~6kxT1(-GorRiZ@>xdKdx1ps z4(*AaSd#Tjz^Me`RVv?ldm%;5o`0(@NSnHGQ~({WMo&S%tjM)#WOv5#VAZ%XDc$So zs^Q;J$AV)gt;fF9!TrUbNOyf9pkV;R-UDb6@_%> z9=GaGU;bw5&C(n`v-^FE?lj2$?;Y!SAj?Z0kkY({ii)*7A@va@pq|vbb!p4>u;a|$ z!Em|h)R>ct8uaM~tX8q`^#HKcrxEKfD=7Z~wjUru;Qzz>->;DLZ8C^eWZbjDhFj;(|51 z=}<1yj^7lzN!_n+b#)~s412AVAf4orzE}8Ynw11bI`v@uJSCZP>qp8E1K&HOVexhG zZ+MtikJazrk9UmRS|iU7De5KndoUw|eKW2E9cLCVssKYYBvFoBJZ)FdK6*8#@`Ga& z^n+0q#k+PY=C=;(((LBJO?&Wc|Jl3Qc>i`E2wpr}#G;T{%P zFb9L)`oOSyMVuFE`2}Y*GWr$j;`-V1F5Kds416<@3y4I<@9{A>k3gN}V{|nCjt!HC zz;zKpUup@53gRqqq+nHDFlH6xi5(jjc@*Lr-YrP>%DV8zMz?BG!`4+ff`BzR@4`J!8DGU{WHj|g z`R(-uPb5cPh#)`RdeCOanYqvV2lp0)CTv+=dO93$9uNM}5I-fV%hypx4MGW+;kRCT45@Ful)uBLGHyhj5KfiE( z_RUc7=2k(A<~tX?Kvwx{m=)8o44(?EirZ6xh>-&mR4^s z5%*V-Eg1yP+{$q+bKkb4B{N$+3h1vSORYC34Ve+47uq^I$I}{Fo$*d=5GmK#k18|o z^LLzb+c+k7zLe!H?3hQk1_XB#jw?r?+mfD6xnf$cwt3)W|O;NPO zDPJ|CM*oV#wr(BlXtth6EXwqvwKz*qvkcJvgjhJA&ueXM2tTsAm`pWzrG&9cJ^6v) z7m*<{Z-iD#q>_ZB+}*a)j~x2STb#g6jG*>UGQ=LI8YMbnq(ag$$EEwV$|P0_BW~PY zWelU=W2^dmbpiJn1-U%1Q6m-Tjbrs_n++hopJi}{xL?rv*nR4V*HP!Z2}dd1sj|EX z)E(jVv2_45N%?L4N|;4d;F^YTk7Z3BEd`wjg-*|XSA_6(4H;>*SeagQ2bwk4{Vex>xF!Un5loG|D zoPY=l8po+dBMELI?`7E;j(hA!AI}tgykNv3UD;>_azl~@)3mJi)cI3lt;L)%Fpy$-`#0Q>cNlmCdpw1# z?4Hu1^ANhxv)K^6&4WC>uuHzm`);fG-9-NISle+rLiz%-h3`^`p_ z`29ilA?IC5pm5#|ABpxuiXtG?Q~^h71SB$hgswzjU};&Y@CE9-upPN6a<%@oUw=n0 z!*8+&Dz#ax_Z8r+F0bi24vclnuRz%cN!1!{y=LFoA)+tUYx*>V<2o#GY1m)la|%+o zSw^vT@IqveC{(U7;2`A^ z*$5qfb!qjEw28daI!d5{I5Y?tOkk~4_?@;SsQ@-BI$L>CoW<92^@wGebJ&9$j^Mbi zS_Cr>FR69xCy*l%IdL9CPsiaO*7Ifi>ui^^#M5W?RaR1QZ&?K7wuSeKH4|k?LMqG} z(s9fbk*xzJ9-F{8!e*v5hEF9$*>K<2Ol|jA z)yS6`vM0%sAs3A)S8_T1%5a#MEu$BlOBi9?3}D=<;jl8*z)=!zTf0xQl)_5-SS#6> zH2m#R*~z6z(Su5(+ewJfTXXG^X+%a>9omM-vug3A>Imq4Swq!RTk2{laVaLc=8{tG zZ6gS0Vk1ECMo*2hKoni6pTCKD4+{IlBq3&J3Mp}>XkBs6vLTfPa&jFV?52Cl%p!p5 zttoz!S7p@sKn8 zfZ=BosrO+KRFvzf*G0=3%&B2w4aL~8)#oxmVLKet%znv8;;9beCE=tM>pBGmVjI77 z($GPEmSLS_0`+9Ux7>yzy3x^;acsY|X>!Y?-Xuh;pO1UGytd{;{b+g08q_#T6eb6| z;su(F(&SdyNhGP1dFy4dKLMV+);flV^z<^lM^zEdKn|W`9*Z?D>T;r}=wr96*9z|P zQjAZPhETli%&}d^v>7#R!oLei`9Dcnj86s2bvi~gfpZE>`TocOfVR;Pp>guIO4|qH z@10))PXDu@M~v!_<@K_8KFHHCSH^*2L?~a+y7k_D> z1f;3sT>4UTxb81U{nq4>{S>GRV{WgFoKE{#llR~S4$7puH%7AWxm;^ZN?%Ni25~{0 zBr3YB#M5+~=a4FFow{di%yV4*_L=-jROMGq_FLBZh>vmHOfe4_e;4XGjcUO=K zUCPRCtDqnLd3j>XZ#K0_>JZq+2Qihv_09=cHtI(#gG6(rg$Ypnq=3X@DJ|~^daGS!*&6x&wl6ewEH~zFq5jNvVzD| zxYWD9f#}#TeG69eF7~LcK-#Z9tFF_&NyvmpW=h}T&~Y3*^L+7eW+e!+hZhm3McCUa zfiDolsw`bCnRpLN>C@L|%m_FOhq+;$aJtL#O9L~*hQ^IrmG_UvS>Wu#)-l8@A@|h% zG^zO7+n7o42LQp;(2zwfb16p3<3I@mmwrv2#>SDJbJCvAg239ppG2il!SuMB&A zcoliyTAUsFLm=vJ?$Ig0*VBaQ3ub?#PozEzdf&p(;VeC|?$^{7&9@#<(W;ubmJk~s zqbtd9(9Yl5tw@p}Rbj2yT7U!Y=s{%p&r*#9iQ{h$NrG9Mgd{ThjGwE88fO@lksl|% zA*mDjE1ry1wagnz)4I^C6L(TzBOl;UE*1K-hCtt!F$}#Vb; z;zFLhTSmsW&Qj_rA~|mBPzO&w^f8Zvbh|fapzqwxuw$o?Yr#$a;g=L7PcDq9DypR8 zi0Giwr47-ulG`9=o?huN0l#=PKXp%iJ%e6`Qmk6E`ESN$bY_9Rr(~oCeBE!lpt~_< z&70aD;ITKuxy*!IJhSUsYLW93i%+>i6^&&_6DXf*Oli3CCBJX>nqjX{&{ zv|J#f@-#M=R@GuCZg)Cs{vcy@hL8r{T-h!ywih|6`H}Tk3oCDqLuTf<_U7w*d&gMk z(iN?H!Z_98ukb7d`X!|BujK}LM)i!tkpa9f1zC!@T^5uosvQz%o)~5&n5P1A$9 z*V!2h&&=i%IL+Jb@0q93aAnub@BT{kYc;^UNTWdmKQM_1pWot;St2JNzgVcGG)GU- z!s()Lh{;sr6`qd_h28}ose}2oXy&d;w{Bf@ij>dt`7hc@KAeJvLT1y==(hJ|SCWwv ztjFvXdzbiH3R01Ml`L|VS;S~l`a8__zi36wtZ}`*b9(tEbnJJD{XHR5Q(oteCh~al zJ`h6*QP5|x#KP(<`aU}!e$?HlM|SQTCt(>2b9bPg8?N|Uemy+K0ttyA|9QeIr&U|? zXv<&8qcA|vlCp25S`|I|!sBFPxZ*>bixa!Zbl_g;QPgkdEiIW(;hnW=ZGJ1)c-^R* zk}7+Av($akylfvk7CnQqn`yW2avFXwZX&)78rXMpd_}|5jtWyx6CvY|sw(x_9PuzOCl$&Ux{4rzH5Avf)eE zse@u3I6jCvRkJ5n;_|D7q)!Ax7Lk2>oX!#ia}!x@!f z(iF;z^){Y$N>c(MkTl4?7K@Q@n@S2m%lX)nIZbapD(KKR_F*zMUrur4^lFcW?sP~r zKweYC?H_PJEH!A5ov-7(_Y(<4NCA-33@r%~S8LSZ9pSg?8pHs-i%R1%YA%>X$gQsZ zhp5q99MPVHSIRwa;Nk?ZuFZKak^bD@e=;$_VMO&beF;0$7WZLa+PR$NVo|^!bzjbt{Km5d& z3;%RP5#@Q7egBIKP$8Upquad$_kZvgC>k;xlD zZL6Hj(@xU+UFV3|sFu!+efwTJ8ygN^PZAW2*UUn|VM^xCfsj~osRQyp zcvfX9!<|o*!fk+w{YWZjJ?>`u8zh6=$^gFjRa_HeuZ&2;c@+)n?GT{5uR#WWed2fH zYxwf9p|ev-q(aNMkm3$(xWoSN5GVzBsPJ<kb@TUqN!XYM zZYazom`3~fb?}FzflmxSsAa9X)6uTva44*f<&sh!1=;vY&&8IRxmTvylrnUd|u?YKeuxV0wx6o{NohFG zZdwJ*+0xv8up0QJ4D2$X6-8sHhe0s@-YdQ~XA2-{nU+A|CQyGh?+Vlz8XJ2ouaGG! ziv|qC7gAwooGNJD+$upP!5ion!B?N~oG1urpRj|X)e*A_bzp`0^E+@v8DogjJk#3d z=8H8=kofN=zK&~fP6Bcd9tX!g43?Th|9lxEyE@sa-|ab`rChTB6cogYO5G8+3SkxM zBB^;%Ou?OdY-^x2Xaw_UYPssaT}VJIa$6Jg zc(a>?$cGQSFK^)-o3Y33%LLdya>Yse0e%Ps&>Eyt2%DtoB*R7`(U_vjj|f=Dd2nGu zJ8j)dD?F-y`s?DLox{!q??U-4o~yuS@^Ti@czFOu)$3-mp9d@kx3ZssyXot`YBqmd zr&Axm7-qM8n}n`%>VEz8JBv;B)7~{_%}kQ<;DVP`A*gNA(O}Py(7Cd*jXM!pF0k>Q z^{=pnO^U`vxK@ULPCNv;a@WYZku0*j0SRk&D-bNXsQzm6N(Y6;rw_8x`Y`m|*?Iru6o|eQ$z3Y~f`yTy!2@gUpksjOpiGF_I=&)~DIv`=aM7a8-qTr0@mUuN!9HFfLtzSV3a; zA1?9KIzKrP1=ZDa$B%ujRLqW#NoO7({fLilZ+FjA5iXKc_%;04kI+FR@GATDcE_^K z$1U`Wr0;$AAgv4lG%>@=(Qq^66$#^-pM;@ISYTY3CO>c-GG!uC`g8Pcc+BK{KATi4 zo^vFh4Za!XT7?o4Dq)x!^1nEp(-*JoDqpT;d*}Aa%DZ&ux4anc!vPc|d4r0IMbrln zEqn>hBEN_sI-4oNl%HXB>6A*@pbzm+9&#h`LIGcFtaNrsb|iQmrx2&L%}Lfe1K6q= z!&UX%XaL&}iMK`>S+1`?ozn)UONJDUMt@qq*@L?pwQn#Jr$G|+q9UEB@n;ESXmp`o zVG+$b^;GyyR8K}#OD9&aG;{zjN9^gNJS2nA6fsv;42-vT0|>zatu=-(-GlKg3{sH7 zW=rz>8v3n8k6o?KBpkk74rAs8!=5VZ&p|Ov(UKwf^!Z*^vYecDGYcA1PELT^P4-87 zfx$tc-}jM1_}BGUWDY)6-%8w&4B4?URF8WHhUI!AWB(_vXN=rdxX^ z(h%jau)D(m{ne8SW#mIDE_E*?6d2%c%IRNU>``B|r{3DL$J!AGc6w^ffq)^5t(+t( z+|Bw#{^Nt?f&OWYQe`IS6?vDXk~i`pk6`~Fo)|{OlY{X&A3BAsNp8!^xLhEik2Ny+ zZ7U(mHY%>2^>KIzC;k0|%BNhEC1Sw04~LTZ8#QZ23PLwsT6FR{caRy-J<@NEg zRv)Epw=#TPZWCI%b~!>z^DQy6N)efz?A$A_!=jP9V^#06qcpCFK}eVph58K8mN;7U zy=t3yBociNWfA?%e_xdw(CHVv%}HQNl}hsR*>*#HB>(4?l9@i>qjx6>N@pieB`6ev z9MKpS3lWgM@qltz;B1^8hg&La$|0CPNpidn7lFG-w&Uo~@g1aj{A*UakG?eI%u9nu z(dmOx9Gl7n?LXo@sm>X0%~%XjGD(#WMc94GQSy7I%>?D3_&P@fROusG5uJAZZ4HK4 zHT6i*-Y=!48!jZ}-utes?@F_0q8FK4Vy!t1r?wTdi3%iOCvzY};Ww37T|(BXD8Xq4 zTmYh_8S2!t1CQ0Ya$GX=g^>kcyO8Zz?YZACJ;9qh(X2 zS%xn0Nv%8jq>K)w#``>CWFU3YR05Jge`@%*`29_!&#EF){Kn~Fv}(HuuQhI}YP5i6 zp6iG}XP#7UqEZ8m&wIEb85j#dHyaX%xwb;&f54P}d>J>Rhyg{P-{+NUk)A02O@fo* zbB2X4*O6HbB@cYPxGK%$c4c7qGBZ;Vel<%;`~VIEfcHLDLam@&rS%C``CnNVdbN*p zkTWY*df}e+%J9#hKj*|Ksvfu1gnwJcXg*5dsGBGWnZrccYF^^MKzo4>>{j08$i0huLS=J<1A2Y-+K>sU`KQID!4- zFiy7#m`m{>(jLcW6*$QWY!@`v^d#u6rZ#DL)Y9S1W92=Vs6;@@cNxE)d7#MYutyre zs1-w*ha8r4Puw!4hCNwLeKAY{3ENOv8=K|#A!(@`wkz-q93)1>tfw`1P)L|yE4dwR z!jl%9DkG2f^qU9QA417eC1qvFcN|GDOoQd&_SAG+S^acMRj<4ZvN}5h{>q{Sd~%NI)zbO&>>_F`^#CLbqEeyiD?7 zga0PtV>rk=(^_R$Pg zG$yU>>C;38vB<-ocb;kOI)9<{2$q%>XTy#PfpU5*JZP4QTfr=({>4~Y0&@^^3a;q; zD*#DjHAlCr6sTuf4dIZ88;iWjT-YpQ%-@dA#Q1n*XE&-q-E zv+~XGlkro4&F5+OBN=vc@;qjjFHCrPJ6omrj`+_$Rj2gDzU8{v-uK;1I4%yk!B1sO zuuM$`_%)mUik~5C43!*=jxT?9<=!jsiEFqmd_hxi2 z=n}R%Jq1qiqYD4t8*3X|>ZB{^s*6jx+;vrXZ4C+kPT3Yk6hBsGOn*JNdld&Kya2*^ zk{9?>5X`{R#>N1KRn4Pa+`$a~E}S>tnE&0(5#=`h)ofYNwcXk+Xd(LPI|X?SscZKm zU@{P{qV(<>845*!9ltO{pv0zq)-?moIfDbG6uxB9({tnsz5J zjsSL^!DwN%#pd1NfyY;a>!`x&EGc_1XWIHce;ZN_w)bNhj>Xc5}Ye*Dcz~{`Y3v-K=F(E=1Vc# zu)F?&9iE+?XEmQ^tpi_PAr=(y$uc@x3%Xewz5k!9w6TOOHPscRbv&Ribh}4%tJzrg zpJvC;&%=O!c$5Dlo84yQ*6eL_<6&@O#o6s13KL3J&qhW4a02pl_g6H5%6*$qOrG{g zX2A!VUrkWV_ak-9xJ`u0Yl0cb8K_1~w#|0UFy-x|d2Vm{o9FH4^iOukgJ$L$`wkI# z&f86wsTqxzfmVOS_|3asY{B91J_re?KATiQ1x4|fFL*#sG3qfF99|J)(i}TAHMJ=S z{Zdl>BAw6j)i=(&j~SattiOyQ@nJkaUXVkfwk#LNTRJ%`z?si;aW&1jI#M2q%*0vZ z*stDHZ3jAw_J3bRM+@-VTj>465=u!493-+IKZ3(Y`^69Y`FVGq$D$^zvK$ODw2;Wc z`@~Lwpg6tB3&`hjRnI|74>4S79;#xzHg34wsUHJ)4aErWt&*CD-_D*L8odRI?WX%n z9smAZx60;eOmm$Ow2MBd;TwD3W1!NG55^v$j&Xp;+2N2>-%A;1@r$L`+^Lrb@1O@@ zEQFUe-+V8|>H&(pnru?f_eu4P7;pZ}A!po%B*qF?M1yAoPG=X}Nj304pf zri-l+pbx1%$*kj9PK_W%6ybCD#}@vu`|A9#4fKmb|DV;AMi$u>DdHehTis(QaFZ`5 zXRf&dr)oOKuY)=~MU!gUJhCo_9nWzC)W)PrpzRJctTE^!)lSmI1WFJ^QXGC&fE zcBtd=t3}^z?8ojJDP$f$>PE|FjCofw{Lfd@%EzOv*}9O<1d_?U4Y1yXt^J1VWIG^R zZoMPTQ5Pnhtxq~q@8x8t>53;~m8>R+nqPg*-6XtS(Z{uK?aHLge0F_2ZVZ^iye`{; zZ3II2S&rXrVS@I$!Ux?f?bDik0vc7<0MWd z&Mtt`Bvh>zTy|q&&(?69<2WEmPu+Gh?L=QDXPD!E(1ZE&c`wq$o{hR7>qeQx%e5@arM)1-D9p}a_3TTtMWHB4T^4sgXK(T9?o3V zXdV51cd8rP`KAC}yOM89Lo*t{eG;#cADCX4E!OHg))hHRN!^^8$=Vt`Ud`{BXd$EWX7d7v17=_Gu^w@T}|{>1W#w=|~^ z?dG|UD(xH02b>zh#60csw++H_sa{;+2NjBJN6~ufE(YcXoJ^ikwMbxYYq5m-6?U;w z=1}rmwI3|gRZp0`W>!Udrib~}0a1Y^zy9hL`~8bJu^nAu$v38au}2fbOftJEin(vc zd1_cfjb9{+hvG2YhWvOEjCG$`j>x}eV{<5O=%-y`=nhcOG~6|CAEL;LCXzgRJB<71 z6fylq(s8D?LKQ;<4QO(!8$boKynYCDTj1|Bp3Z@f6(WQ47_b>I%_GTPskM7fJLqut zBsPQwUSE)fif)M_c<^m?T7ca(`2qVN(2YH#Y9yPHvgC1JA3mD`k_AR=U$j{6|9s=i z_sZJ7@X$&P<9)&+j*+<{OBQy^pWp^{ZW0z2JNSBr5123vc{E({2tsEvqumBYx~NiS zWPZF|%^-PL*;j4s+QS^NvkQL9vb_s=3d3ix)NU+uwl}&N_bEaor(+3KvUHW4&>JzW zvt1Nm!p!T#}#9Sc) zOyJ65w|{%|GT~JEtp>~AU)ma+{?#d{S}^nBtqy2vr)r_zXEX?|63R(^1~r zx=tY?*V93y! zgHpher5Sv54+AG!?HHNhuVlu$n@BLHse#cu*eQH_b%=Kk0e)F-R~e=$+pfQhR56CK zT=`+6w!KJa)oPbEVtMu=O3qBkC-UgKM@fvts}-EkJs@(UV>9{lcR(-uhhIOzZKBPC zk6~{TDbtH$%f-@t$QnB4y(z;+ck)Px6+W#ky=1*KpAHGN^n_uxCA_cU6HhX->qcri zs%ogAqKk_pn6nrN91F;M9vmpiA>uez$qCz%Is} z9X{l$ia{mm?~PxLNJtvV@in(O7P5qEebwkGD2|o+lIxcEB8XBtfbl4`S-lc6s)}*nVxOe)NWJjw^9FScIVxE6 z_P#;dvUZt*IZNn5+WTh*^_?*|R-(k+$@gl(><>l>drP7p!d76GXiZfDTP*`(^(BEg zBk93LkJ8$b506^W^X+~@kFPxc?5lquC?S=(P9Po8^r0vt4Y4)Z|M;xBzriG=Q~=?= z5P}^E28t3X<3H&8LiLWyTtW+GP^v_6X%(PHeN+A<#N-6r)sMY7?C70o%s3%$r*qrI zeHYS4r15}yd>ncg?^vGN_d+sB%ia`WaoIfzW3b#kF*Vd27MJf8;XiN6QJ`lzYj4)IfUfi_JKl(7|7dJFZ-x^V7k@rX99K@=*d%{) ze2P5tvk2tC_Vs>{q(+&Rr;P$agE+{o`SqF{k%Wa>uj|^4UdqQ`l0-#6R1;$K#J{{V zfV{6t1*S*WEIJ?UyO98TCOPEZ?V8%}*qiVaEBi~`Y1Z3@4T2Y$Tbh43)a8?`>y8mg zLeY0Y=j8bueDu4Gy=P?f3iZi(Pb2EB{y#_oC|pYq_F|wB#nAJLOATrpkRYuHhX>ie zI>bgIniGf;ld|0$;NAX%rQ59V5^P`k^xR&08rMh2LQw{h+HCp57mXHYfR!wk&a}IVf>};QdyN<9q7!_7Ysu5G;I{$-uv2?P4$OPZLJGq%E?-9r`EW zqwb0@VU>mwQBs*u>@WMMyQX+-hTkePKYKogWJpa1o{f>;Kyx6vKTQzU@2NG?UVPo* z5b;`Q0t$sD1`)Xc@>K2{LE<+;P>Yy3->s6dTF@%rQuVasmz9{9RDS43?GNgUz|;2Y z7>L?$p3M0l4#0Hu;kn~g!}4uLet&;2jQs%p9TfmEJAs7nkqtZgh%1X(2bm5&h|F+6 zV)96z0rVh+SPGknjs^s*l~);%Umzsm0a$q8`EJ%s0KRWoVKY(ylR~}Yr0lYLakk|0 zG-iLf>oS%Y3T028c50RY$~8?z1jxQD$gh04C>z9fBG_aLTUx1gSvH%oC)lrl|7Heh zGhTZ4OfRkv09Pj^_^g{4Yro=K`%dO_cP5v(t>JvRHZWEFsR8G*(o9SdMLr%3D`QhvJ2KL#b_%5ZU-`(?Rc}HV~g*W1*tEU9tD$g(qGe z&>xt?lfHZH(t#rF132Be zaT&JmEw;7wXYjXWi?|s`>VutHzzt2iOulais`L4Fa!F>C_{}K4V_bNhZ-tEOA3tqG`A$MQ5h2MSSKO6ps dCJzd4(IadTuWT&PveCewlDs;yRMsT)e*tp*JK_KU literal 0 HcmV?d00001 diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_edit.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_edit.png new file mode 100644 index 0000000000000000000000000000000000000000..936cbf114c6e10b745c7edb75c60ff881bbf0d74 GIT binary patch literal 58840 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 literal 0 HcmV?d00001 diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png new file mode 100644 index 0000000000000000000000000000000000000000..b1c5ff50ed75c8662561b4626ba805c8b1b3998d GIT binary patch literal 72754 zcmd43byQT}-!`mLgHl6C*H9`AQc{9LNh>Mc-QChdcS)mwN{4iJBhooENDSRE&*A(1 zt#>{5z25iz>simb`Dd1A&YZLN{_Ia(*R>5(Qjo%aO7`^8qes{>($Ei&9z6j+dW5nK zLIdQ5&L0~J$opWDlHX`<}DLi)Koyr}neVn96nQLSPgy@e6D(TQV$6z1)k z^WP?s!IP6eGkH~+jUeKf`{fZ^F&E7Cqli7=Pbk|X%ghuBN8b1$ zM?thfc;bmqcmnXmLA3M`;2$7b@WVAw*gs!ML;v>=2=HwX9zOWtBNp~>ZxHmKpZw?J zfARygO(1zXjjpEaeaf2+yQSlu_`vY#As>kG?3#{3uEw+Ptr_J~U(h~GUK;w1k;eBp zfp5qUSf%8Tg$n8Ewhc14a{tU*TIa3M&0ZBFH}^+e+5hLt-RU^~zbgVYoU5@RXQJWa z;yR7}XDMO!bJbx)c#1+t-%$TqFK}GyRLOiQ-_oT;*k||SekS=}mXoLU|GD|oFH0NW z>v29=)UK5;d^3ziCG<_@bbnqER|ObM z-JAq8;yG)v`5`K*_vMPzCLDRM_~UA_Y-t*W2$*rHD|qb3N2Zf>_${L(G%*Q&f9~d) z1ANgn$p$H{-y|FVDis5Zy|f7f(NtI1uRyr1riwpsTKq81q!Q9UA6?7l-~kWcot4gQd;s)|ON{f2q}EE!#1a z|ExS1-)9Je+8b{zuI_9~>+C;EwGK>Wx(Vr999y?vGk==%@0zTi?oN*)u9h*wIzw?~ zrGbe8+wjNJuzOB%!_h3^%^q^Q2^50=jHA_0Mf!G)8u?l_g^g!g&###(^`Ctm*AS~w zXP;ZY5<-4j{?BtCo >pO1exxA_0(9{SDWc)G=pioWvxr=TU+T1 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 P@K5%QBD`2a&-ecT_p^~x literal 0 HcmV?d00001