diff --git a/apps/api/src/adapters/http/routes/driver-route.ts b/apps/api/src/adapters/http/routes/driver-route.ts index 3f88f1fc..f9407d0f 100644 --- a/apps/api/src/adapters/http/routes/driver-route.ts +++ b/apps/api/src/adapters/http/routes/driver-route.ts @@ -26,6 +26,7 @@ import { readSkillPackageBytesFromSnapshot } from "../../../modules/skills/appli import { createErrorLogContext, logError, logWarn } from "../../../platform/cloudflare/logger"; import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; import { toArrayBuffer } from "../../../shared/bytes"; +import { redactProviderCredentials } from "../../../shared/provider-error"; import { isTruthy } from "../../../shared/truthiness"; import { platformIdRouteErrorMessage } from "./platform-id-route-error"; @@ -325,7 +326,20 @@ function buildLlmProxyUpstreamHeaders(incoming: Headers, target: RuntimeLlmProxy const lowered = key.toLowerCase(); if (!HOP_BY_HOP_HEADERS.has(lowered) && !LLM_PROXY_GRANT_HEADERS.has(lowered)) { - headers.set(key, value); + if (target.vendor.vendorId !== "opencode-go" || !lowered.startsWith("x-opencode-")) + headers.set(key, value); + } + } + + if (target.vendor.vendorId === "opencode-go") { + // Bind Go routing/cache identity to the Host conversation, not a replaceable + // Driver or a sandbox-supplied header. The raw provider key stays in Host. + if (target.sessionId === undefined) + throw new RuntimeLlmProxyError("Go session is unavailable.", 403); + headers.set("User-Agent", target.userAgent); + headers.set("x-opencode-session", target.sessionId); + if (target.modelProtocol === "anthropic-messages" && !headers.has("anthropic-version")) { + headers.set("anthropic-version", "2023-06-01"); } } @@ -359,6 +373,28 @@ function buildLlmProxyUpstreamHeaders(incoming: Headers, target: RuntimeLlmProxy return headers; } +async function readSafeProviderError(response: Response, apiKey: string): Promise { + const reader = response.body?.getReader(); + if (reader === undefined) return ""; + const chunks: Uint8Array[] = []; + let bytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > 64 * 1024) { + await reader.cancel(); + return JSON.stringify({ error: "Provider request failed." }); + } + chunks.push(value); + } + const decoder = new TextDecoder(); + return redactProviderCredentials( + chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join("") + decoder.decode(), + apiKey, + ); +} + async function proxyRuntimeLlmRequest( request: Request, subPath: string, @@ -381,11 +417,27 @@ async function proxyRuntimeLlmRequest( init, ); - return new Response(response.body, { - headers: copyProxyResponseHeaders(response.headers), - status: response.status, - statusText: response.statusText, - }); + const protectGoError = target.vendor.vendorId === "opencode-go" && !response.ok; + const headers = copyProxyResponseHeaders(response.headers); + if (protectGoError) { + for (const [name, value] of headers) { + headers.set(name, redactProviderCredentials(value, target.apiKey)); + } + for (const name of ["content-encoding", "content-md5", "digest", "content-digest", "etag"]) + headers.delete(name); + } + return new Response( + protectGoError && response.body !== null + ? await readSafeProviderError(response, target.apiKey) + : response.body, + { + headers, + status: response.status, + statusText: protectGoError + ? redactProviderCredentials(response.statusText, target.apiKey) + : response.statusText, + }, + ); } async function proxyRuntimeMcpRequest( @@ -574,6 +626,7 @@ export function registerDriverRoute(app: Hono) { try { target = await resolveRuntimeLlmProxyTarget(c.env, { credentialId: grant.resourceId, + driverInstanceId: grant.driverInstanceId, modelProtocol: grant.modelProtocol, projectId: grant.projectId, }); diff --git a/apps/api/src/modules/runtime/application/runtime-llm-proxy.service.ts b/apps/api/src/modules/runtime/application/runtime-llm-proxy.service.ts index 4e217053..f3ed1b26 100644 --- a/apps/api/src/modules/runtime/application/runtime-llm-proxy.service.ts +++ b/apps/api/src/modules/runtime/application/runtime-llm-proxy.service.ts @@ -1,13 +1,17 @@ import type { PresetModelProtocol } from "@mosoo/contracts/models"; -import type { DriverInstanceId, ProjectId, VendorCredentialId } from "@mosoo/id"; +import type { DriverInstanceId, ProjectId, SessionId, VendorCredentialId } from "@mosoo/id"; import { getVendor } from "@mosoo/runtime-catalog"; import type { RuntimeCatalogVendor } from "@mosoo/runtime-catalog"; import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; +import { getWorkerUserAgent } from "../../../platform/cloudflare/worker-user-agent"; import { isTruthy } from "../../../shared/truthiness"; import { readSecret } from "../../vault/application/vault-secret-store"; import { getProjectCredentialRow } from "../../vendor-credentials/application/vendor-credential.repository"; -import { isDriverInstanceActive } from "../infrastructure/driver-instance/driver-instance-record.repository"; +import { + getDriverInstanceRecord, + isDriverInstanceActive, +} from "../infrastructure/driver-instance/driver-instance-record.repository"; /** * Upstream target for one proxied model call. `apiKey` is the raw vendor @@ -16,6 +20,8 @@ import { isDriverInstanceActive } from "../infrastructure/driver-instance/driver */ export interface RuntimeLlmProxyTarget { apiKey: string; + userAgent: string; + sessionId?: SessionId; modelProtocol: PresetModelProtocol; upstreamBaseUrl: string; vendor: RuntimeCatalogVendor; @@ -48,6 +54,7 @@ export async function resolveRuntimeLlmProxyTarget( bindings: ApiBindings, input: { credentialId: VendorCredentialId; + driverInstanceId: DriverInstanceId; modelProtocol: PresetModelProtocol; projectId: ProjectId; }, @@ -86,7 +93,16 @@ export async function resolveRuntimeLlmProxyTarget( throw new RuntimeLlmProxyError("Vendor upstream endpoint is not configured.", 502); } + let sessionId: SessionId | undefined; + if (vendor.vendorId === "opencode-go") { + const driver = await getDriverInstanceRecord(bindings.DB, input.driverInstanceId); + if (driver === null) throw new RuntimeLlmProxyError("Driver session is unavailable.", 403); + sessionId = driver.sandboxSessionId; + } + return { + ...(sessionId === undefined ? {} : { sessionId }), + userAgent: getWorkerUserAgent(bindings.CF_VERSION_METADATA), apiKey: await readSecret(bindings.DB, bindings, credential.apiKeySecretId), modelProtocol: input.modelProtocol, upstreamBaseUrl, diff --git a/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-execution-spec.builder.ts b/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-execution-spec.builder.ts index b7a483ee..579b1737 100644 --- a/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-execution-spec.builder.ts +++ b/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-execution-spec.builder.ts @@ -202,10 +202,10 @@ export async function buildExecutionSpec( provider: input.profile.provider, providerOptions: input.profile.providerOptions, session: { - // Host cache and native HOME are provisioned separately; they are not - // optional directories requested from the OpenCode ACP provider. + // Host cache and native HOME are provisioned separately from optional + // directories advertised to the runtime CLI. additionalDirectories: - input.profile.runtimeId === "acp-fallback" + input.profile.runtimeId === "acp-fallback" || input.profile.runtimeId === "pi" ? [] : listProvisioningDirectories(input.profile), context: { diff --git a/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-files.service.ts b/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-files.service.ts index 821b9de9..82513e55 100644 --- a/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-files.service.ts +++ b/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-files.service.ts @@ -15,7 +15,7 @@ async function sha256(value: string): Promise { } export async function ensureProvisioningDirectories( - session: ExecutionSessionHandle, + session: Pick, profile: DriverProfileConfig, ): Promise { const directories = listProvisioningDirectories(profile); diff --git a/apps/api/src/modules/sessions/application/session-process-events.service.ts b/apps/api/src/modules/sessions/application/session-process-events.service.ts index 7aa19d2a..87f964af 100644 --- a/apps/api/src/modules/sessions/application/session-process-events.service.ts +++ b/apps/api/src/modules/sessions/application/session-process-events.service.ts @@ -94,7 +94,22 @@ function toProcessEventProjectionFromSessionEventRow( export function createSessionProcessEventsFromFoldedRows( rows: SessionEventProcessRow[], ): SessionProcessEvent[] { - return finalizeProcessEventDurations(rows.map(toProcessEventProjectionFromSessionEventRow)); + const projections = rows.map(toProcessEventProjectionFromSessionEventRow); + const latestUsage = new Map>(); + for (const [index, row] of rows.entries()) { + if (row.run_id === null || row.usage_key == null || row.tokens === null) continue; + let calls = latestUsage.get(row.run_id); + if (calls === undefined) { + calls = new Map(); + latestUsage.set(row.run_id, calls); + } + // Usage snapshots replace the same Run/call in the model-call ledger. + // Keep their history, but count only the latest snapshot in replay totals. + const previous = calls.get(row.usage_key); + if (previous !== undefined) previous.event.tokens = null; + calls.set(row.usage_key, projections[index]!); + } + return finalizeProcessEventDurations(projections); } function createProcessEventsTruncatedEvent(input: { diff --git a/apps/api/src/modules/sessions/infrastructure/session-event-read.repository.ts b/apps/api/src/modules/sessions/infrastructure/session-event-read.repository.ts index 1ae79fc5..1e29eeeb 100644 --- a/apps/api/src/modules/sessions/infrastructure/session-event-read.repository.ts +++ b/apps/api/src/modules/sessions/infrastructure/session-event-read.repository.ts @@ -19,6 +19,7 @@ export interface SessionEventProcessRow extends StreamFoldableSessionEventRow { tool_call_id: string | null; tool_input_json: string | null; tool_name: string | null; + usage_key?: string | null; } export function sessionEventThoughtColumns() { @@ -156,6 +157,15 @@ export async function readSessionEventRowsAfterSeq( tool_input_json: sessionEventsTable.toolInputJson, tool_name: sessionEventsTable.toolName, tokens: sessionEventsTable.tokens, + usage_key: sql`CASE + WHEN ${sessionEventsTable.eventType} = 'usage.updated' + AND ${sessionEventsTable.canonicalEventJson} IS NOT NULL + THEN COALESCE(json_extract(${sessionEventsTable.canonicalEventJson}, '$.payload.callId'), '') + END`.mapWith((value: string) => { + // Match the ledger's JavaScript trim, including non-space whitespace. + const callId = value.trim(); + return callId.length > 0 ? `model_call:${callId}` : "run_usage"; + }), }) .from(sessionEventsTable) .where(rangeConditions({ ...input, throughSeq })) diff --git a/apps/api/src/modules/vendor-credentials/application/vendor-credential-probe.ts b/apps/api/src/modules/vendor-credentials/application/vendor-credential-probe.ts index 11434969..b0cb7df5 100644 --- a/apps/api/src/modules/vendor-credentials/application/vendor-credential-probe.ts +++ b/apps/api/src/modules/vendor-credentials/application/vendor-credential-probe.ts @@ -1,6 +1,8 @@ import type { PresetModelProtocol } from "@mosoo/contracts/models"; import type { RuntimeCatalogVendor } from "@mosoo/runtime-catalog"; +import { getWorkerUserAgent } from "../../../platform/cloudflare/worker-user-agent"; +import { redactProviderCredentials } from "../../../shared/provider-error"; import type { ProviderFetchProxyConfig } from "./provider-fetch-proxy"; import { fetchViaProviderProxy } from "./provider-fetch-proxy"; @@ -24,7 +26,17 @@ export function toVendorProbeAuthHeaders( vendor: RuntimeCatalogVendor, apiKey: string, modelProtocol?: PresetModelProtocol | null, + versionMetadata?: Pick, ): Record { + if (vendor.vendorId === "opencode-go") { + return { + Authorization: `Bearer ${apiKey}`, + "User-Agent": getWorkerUserAgent(versionMetadata), + // A manual credential probe is its own short-lived conversation. + "x-opencode-session": crypto.randomUUID(), + ...(modelProtocol === "anthropic-messages" ? { "anthropic-version": "2023-06-01" } : {}), + }; + } if (vendor.vendorId === "openai-compatible") { if (modelProtocol === "anthropic-messages") { return { "anthropic-version": "2023-06-01", "x-api-key": apiKey }; @@ -77,11 +89,10 @@ function toVendorProbeErrorCode(status: number, detail?: string | null): string } function sanitizeProviderErrorDetail(detail: string | null | undefined): string | undefined { - const normalized = detail - ?.trim() - .replaceAll(/\s+/gu, " ") - .replaceAll(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer ***") - .replaceAll(/\b(sk|rk|pk)-[A-Za-z0-9_*.-]+/gu, "$1-***"); + const normalized = + detail === null || detail === undefined + ? undefined + : redactProviderCredentials(detail.trim().replaceAll(/\s+/gu, " ")); return normalized === undefined ? undefined : normalized.slice(0, 180); } diff --git a/apps/api/src/modules/vendor-credentials/application/vendor-credential-test.ts b/apps/api/src/modules/vendor-credentials/application/vendor-credential-test.ts index fea6dd81..87ab420a 100644 --- a/apps/api/src/modules/vendor-credentials/application/vendor-credential-test.ts +++ b/apps/api/src/modules/vendor-credentials/application/vendor-credential-test.ts @@ -4,7 +4,12 @@ import type { TestVendorCredentialResult, } from "@mosoo/contracts/vendor-credential"; import type { RuntimeCatalogVendor } from "@mosoo/runtime-catalog"; -import { VENDOR_OPENAI_COMPATIBLE, getPresetModel, getVendor } from "@mosoo/runtime-catalog"; +import { + VENDOR_OPENAI_COMPATIBLE, + getDefaultModelIdForVendor, + getPresetModel, + getVendor, +} from "@mosoo/runtime-catalog"; import { captureServerProductEvent, @@ -37,6 +42,7 @@ export interface VendorCredentialProbeInput { timeoutMs?: number; vendorId: string; verifyModelProtocol?: boolean; + versionMetadata?: Pick; } function modelProbeRequest( @@ -118,6 +124,7 @@ async function probeModelProtocol(input: { timeoutMs: number; vendor: RuntimeCatalogVendor; verifyResponse: boolean; + versionMetadata: Pick | undefined; }): Promise<{ errorCode?: string; ok: boolean }> { const request = modelProbeRequest(input.modelId, input.modelProtocol); const url = new URL(input.baseUrl); @@ -136,7 +143,12 @@ async function probeModelProtocol(input: { headers: { Accept: "application/json", "Content-Type": "application/json", - ...toVendorProbeAuthHeaders(input.vendor, input.apiKey, input.modelProtocol), + ...toVendorProbeAuthHeaders( + input.vendor, + input.apiKey, + input.modelProtocol, + input.versionMetadata, + ), }, method: "POST", }, @@ -208,6 +220,7 @@ export async function testVendorCredential( const result = await probeVendorCredential({ ...input, + versionMetadata: bindings.CF_VERSION_METADATA, fetchProxy: resolveProviderFetchProxy(bindings), modelProtocol: normalizeCredentialModelProtocol(input.vendorId, input.modelProtocol), verifyModelProtocol: Boolean(input.modelId?.trim()), @@ -233,13 +246,17 @@ export async function probeVendorCredential( const apiKey = input.apiKey.trim(); const apiBase = normalizeApiBase(input.apiBase); const fetchProxy = input.fetchProxy ?? null; - const modelId = input.modelId?.trim() || null; const timeoutMs = input.timeoutMs ?? 10_000; const vendor = getVendor(input.vendorId); if (vendor === null) { throw new Error(`Unknown vendor: ${input.vendorId}.`); } + // Go's public model list does not authenticate a subscription key. + const requiresInferenceProbe = vendor.vendorId === "opencode-go"; + const modelId = + input.modelId?.trim() || + (requiresInferenceProbe ? getDefaultModelIdForVendor(vendor.vendorId) : null); const modelIdRequired = vendor.vendorId === VENDOR_OPENAI_COMPATIBLE.vendorId; const modelProtocol = input.modelProtocol ?? @@ -284,7 +301,7 @@ export async function probeVendorCredential( }); } - if (modelIdRequired && modelId === null) { + if ((modelIdRequired || requiresInferenceProbe) && modelId === null) { return finishCredentialTest({ baseUrl, errorCode: "missing_model_id", @@ -298,11 +315,12 @@ export async function probeVendorCredential( let errorCode: string | undefined; try { - if (input.verifyModelProtocol && modelId !== null) { + if ((input.verifyModelProtocol || requiresInferenceProbe) && modelId !== null) { const result = modelProtocol === null ? { errorCode: "unknown_model_protocol", ok: false } : await probeModelProtocol({ + versionMetadata: input.versionMetadata, apiKey, baseUrl, fetchProxy, @@ -325,7 +343,7 @@ export async function probeVendorCredential( { headers: { Accept: "application/json", - ...toVendorProbeAuthHeaders(vendor, apiKey, modelProtocol), + ...toVendorProbeAuthHeaders(vendor, apiKey, modelProtocol, input.versionMetadata), }, method: "GET", }, @@ -343,6 +361,7 @@ export async function probeVendorCredential( if (!ok) { if (modelProtocol !== null) { const modelProbe = await probeModelProtocol({ + versionMetadata: input.versionMetadata, apiKey, baseUrl, fetchProxy, @@ -365,6 +384,7 @@ export async function probeVendorCredential( errorCode = "missing_model_id"; } else if (modelProtocol !== null) { const modelProbe = await probeModelProtocol({ + versionMetadata: input.versionMetadata, apiKey, baseUrl, fetchProxy, diff --git a/apps/api/src/platform/cloudflare/worker-user-agent.ts b/apps/api/src/platform/cloudflare/worker-user-agent.ts new file mode 100644 index 00000000..e8e85f09 --- /dev/null +++ b/apps/api/src/platform/cloudflare/worker-user-agent.ts @@ -0,0 +1,4 @@ +/** Use the deployed Worker revision; local callers have no deployed version. */ +export function getWorkerUserAgent(metadata?: Pick): string { + return `mosoo/${metadata?.id || "dev"}`; +} diff --git a/apps/api/src/shared/provider-error.ts b/apps/api/src/shared/provider-error.ts new file mode 100644 index 00000000..9ff4c2f9 --- /dev/null +++ b/apps/api/src/shared/provider-error.ts @@ -0,0 +1,7 @@ +// Provider failures can echo credentials in arbitrary response text. +export function redactProviderCredentials(value: string, apiKey?: string): string { + const redacted = apiKey ? value.replaceAll(apiKey, "***") : value; + return redacted + .replaceAll(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer ***") + .replaceAll(/\b((?:sk|rk|pk)-|oc_sk_)[A-Za-z0-9_*.-]+/gu, "$1***"); +} diff --git a/apps/api/tests/api-driver-boundary.test.ts b/apps/api/tests/api-driver-boundary.test.ts index 511a4261..4ec7428e 100644 --- a/apps/api/tests/api-driver-boundary.test.ts +++ b/apps/api/tests/api-driver-boundary.test.ts @@ -30,6 +30,7 @@ import { } from "../src/modules/runtime/infrastructure/runtime-boot-token"; import { buildExecutionSpec } from "../src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-execution-spec.builder"; import type { RuntimeExecutionSpecBindings } from "../src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-execution-spec.builder"; +import { ensureProvisioningDirectories } from "../src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-driver-files.service"; import { createInitialSessionLiveState } from "../src/modules/sessions/application/session-live-state.service"; import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; import { @@ -55,21 +56,49 @@ const artifactPaths = { }; describe("API to driver boundary", () => { - test("OpenCode provisioning does not request unsupported ACP additional directories", async () => { - const profile = createDriverProfile(); - const execution = await buildExecutionSpec(bindings, { - builtInTools: [], - driverInstanceId: API_DRIVER_BOUNDARY_IDS.driverInstance, - profile: { ...profile, runtimeId: "acp-fallback" }, - requestUrl: "https://api.example.com/", - resolvedMcpServers: [], - resolvedSkills: [], - resolvedSkillCatalog: [], - }); - expect(execution.session.additionalDirectories).toEqual([]); - expect(execution.session.cwd).toBe(profile.session.sessionOrganizationPath); - expect(execution.session.context.homePath).toBe(profile.session.homePath); - }); + test.each(["acp-fallback", "pi"] as const)( + "keeps %s execution inside its Session without unsupported directory extensions", + async (runtimeId) => { + const profile = createDriverProfile(); + const execution = await buildExecutionSpec(bindings, { + builtInTools: createDefaultAgentBuiltInTools(), + driverInstanceId: API_DRIVER_BOUNDARY_IDS.driverInstance, + profile: { + ...profile, + runtimeId, + model: "deepseek-v4.1-flash", + provider: "opencode-go", + vendorCredential: { + ...profile.vendorCredential, + vendorId: "opencode-go", + }, + }, + requestUrl: "https://api.example.com/api/driver/connect", + resolvedMcpServers: [], + resolvedSkillCatalog: [], + resolvedSkills: [], + }); + + expect(execution.session.additionalDirectories).toEqual([]); + expect(execution.session.cwd).toBe(profile.session.sessionOrganizationPath); + expect(execution.session.context.homePath).toBe(profile.session.homePath); + + const commands: string[] = []; + await ensureProvisioningDirectories( + { + async exec(command) { + commands.push(command); + return { exitCode: 0, stderr: "", stdout: "", success: true }; + }, + }, + { ...profile, runtimeId }, + ); + expect(commands).toHaveLength(1); + expect(commands[0]).toContain("/workspace/cache"); + expect(commands[0]).toContain(profile.session.homePath); + expect(commands[0]).toContain(profile.session.sessionOrganizationPath); + }, + ); test.each(["cattle", "pet"] as const)( "requires a committed native checkpoint even when the boot input retains a historical %s label", diff --git a/apps/api/tests/driver-llm-proxy-route.test.ts b/apps/api/tests/driver-llm-proxy-route.test.ts index 1085eb78..6b73a53b 100644 --- a/apps/api/tests/driver-llm-proxy-route.test.ts +++ b/apps/api/tests/driver-llm-proxy-route.test.ts @@ -3,17 +3,19 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import type { PresetModelProtocol } from "@mosoo/contracts/models"; import { driverInstancesTable, vendorCredentialsTable } from "@mosoo/db"; import { parsePlatformId } from "@mosoo/id"; -import type { DriverInstanceId, ProjectId, VendorCredentialId } from "@mosoo/id"; +import type { AccountId, DriverInstanceId, ProjectId, VendorCredentialId } from "@mosoo/id"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { registerDriverRoute } from "../src/adapters/http/routes/driver-route"; +import { getAccountViewer } from "../src/modules/auth/application/viewer-auth.service"; import { getRuntimeDriverLlmProxyPath } from "../src/modules/runtime/domain/runtime-driver-routes"; import { finalizeDriverInstance } from "../src/modules/runtime/infrastructure/driver-instance/lifecycle"; import { createRuntimeActionToken } from "../src/modules/runtime/infrastructure/runtime-boot-token"; import type { RuntimeActionTokenPayload } from "../src/modules/runtime/infrastructure/runtime-boot-token"; import { buildModelConnection } from "../src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-model-connection.builder"; import { storeSecret } from "../src/modules/vault/application/vault-secret-store"; +import { testVendorCredential } from "../src/modules/vendor-credentials/application/vendor-credential-test"; import { runWithRequestLogContext } from "../src/platform/cloudflare/logger"; import type { ApiBindings, ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; import { @@ -1362,3 +1364,290 @@ describe("driver LLM proxy route", () => { expect(await response.json()).toEqual({ error: "LLM proxy upstream request failed." }); }); }); + +describe("OpenCode Go subscription routing", () => { + test.each([ + ["openai-chat-completions", "/chat/completions", "deepseek-v4.1-flash"], + ["anthropic-messages", "/messages", "minimax-m2.7"], + ["openai-responses", "/responses", "gpt-5.6-luna"], + ] as const)( + "Given Go %s, When the Driver is replaced, Then proxy auth and conversation identity stay stable", + async (modelProtocol, path, modelId) => { + const { bindings, database } = await setupFixture({ vendorId: "opencode-go" }); + bindings.CF_VERSION_METADATA = { id: "go-release-a", tag: "", timestamp: "" }; + const captured = captureUpstreamFetch(); + for (const driverInstanceId of [DRIVER_INSTANCE_ID, OTHER_DRIVER_INSTANCE_ID]) { + if (driverInstanceId === OTHER_DRIVER_INSTANCE_ID) { + bindings.CF_VERSION_METADATA = { id: "go-release-b", tag: "", timestamp: "" }; + await database + .app() + .update(driverInstancesTable) + .set({ status: "failed" }) + .where(eq(driverInstancesTable.id, DRIVER_INSTANCE_ID)) + .run(); + await insertDriverInstance(database, "ready", { driverInstanceId }); + } + const grant = await createLlmProxyGrant(bindings, { + driverInstanceId, + modelProtocol, + modelId, + }); + const response = await dispatch( + bindings, + llmProxyRequest(path, { + method: "POST", + headers: { + authorization: `Bearer ${grant}`, + "content-type": "application/json", + "user-agent": "generic-sdk/1.0", + "x-opencode-session": "untrusted-session", + "x-opencode-custom-routing": "untrusted-route", + }, + body: JSON.stringify({ model: modelId, stream: true, messages: [], input: "test" }), + }), + ); + expect(response.status).toBe(200); + const upstream = captured.at(-1)!; + expect(upstream.url).toBe(`https://opencode.ai/zen/go/v1${path}`); + expect(upstream.headers.get("authorization")).toBe(`Bearer ${UPSTREAM_API_KEY}`); + expect(upstream.headers.get("user-agent")).toBe(`mosoo/${bindings.CF_VERSION_METADATA.id}`); + expect(upstream.headers.get("x-opencode-session")).toBe(PUBLIC_API_TEST_IDS.ownerSession); + expect(upstream.headers.get("x-api-key")).toBeNull(); + expect(upstream.headers.has("x-opencode-custom-routing")).toBe(false); + expect(JSON.stringify([...upstream.headers])).not.toContain(grant); + } + await database + .app() + .update(driverInstancesTable) + .set({ status: "failed" }) + .where(eq(driverInstancesTable.id, OTHER_DRIVER_INSTANCE_ID)) + .run(); + const revoked = await createLlmProxyGrant(bindings, { + driverInstanceId: OTHER_DRIVER_INSTANCE_ID, + modelProtocol, + modelId, + }); + expect( + ( + await dispatch( + bindings, + llmProxyRequest(path, { + method: "POST", + headers: { authorization: `Bearer ${revoked}` }, + body: JSON.stringify({ model: modelId }), + }), + ) + ).status, + ).toBe(403); + expect(captured).toHaveLength(2); + }, + ); +}); + +test.each([["openai-chat-completions", "/chat/completions", "deepseek-v4.1-flash"]] as const)( + "Given Go %s, When real OpenCode and Pi call a tool, Then the Host uses the Go wire contract", + async (modelProtocol, path, modelId) => { + const { bindings } = await setupFixture({ vendorId: "opencode-go" }); + bindings.CF_VERSION_METADATA = { id: "go-cli-release", tag: "", timestamp: "" }; + for (const runtimeId of ["acp-fallback", "pi"] as const) { + let calls = 0; + const captured = captureUpstreamFetch(() => + piProxyProtocolResponse(modelProtocol, ++calls === 1), + ); + const proxy = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: (request) => dispatch(bindings, request), + }); + try { + const connection = await buildModelConnection({ + bindings, + driverInstanceId: DRIVER_INSTANCE_ID, + profile: { + model: modelId, + modelProtocol, + runtimeId, + vendorCredential: { + apiBase: null, + credentialId: CREDENTIAL_ID, + projectId: PROJECT_ID, + models: [modelId], + vendorId: "opencode-go", + }, + }, + requestUrl: `http://127.0.0.1:${proxy.port}/api/sessions`, + }); + const events = + runtimeId === "pi" + ? await runPiProxyTurn(connection, { provider: "opencode-go", model: modelId }) + : await runOpenCodeProxyTurn(connection, { provider: "opencode-go", model: modelId }); + expect(calls).toBe(2); + expect(JSON.stringify(events)).toContain(PI_PROXY_TOOL_PROOF); + expect(JSON.stringify(events)).toContain(PI_PROXY_FINAL_TEXT); + expect(captured[1]?.body).toContain(PI_PROXY_TOOL_PROOF); + const completedMessages = events.filter((event) => { + if (runtimeId === "acp-fallback") return event["type"] === "step_finish"; + const message = event["message"]; + return ( + event["type"] === "message_end" && + typeof message === "object" && + message !== null && + "role" in message && + message.role === "assistant" + ); + }); + expect(completedMessages).toHaveLength(2); + for (const message of completedMessages) { + expect(message).toMatchObject( + runtimeId === "pi" + ? { message: { usage: { input: 10, output: 10 } } } + : { part: { tokens: { input: 10, output: 10 } } }, + ); + } + for (const request of captured) { + const upstreamUrl = new URL(request.url); + expect(upstreamUrl.origin + upstreamUrl.pathname).toBe( + `https://opencode.ai/zen/go/v1${path}`, + ); + expect(upstreamUrl.search).toBe(""); + expect(request.headers.get("authorization")).toBe(`Bearer ${UPSTREAM_API_KEY}`); + expect(request.headers.get("x-api-key")).toBeNull(); + expect(request.headers.get("user-agent")).toBe( + `mosoo/${bindings.CF_VERSION_METADATA.id}`, + ); + expect(request.headers.get("x-opencode-session")).toBe(PUBLIC_API_TEST_IDS.ownerSession); + const grant = connection.proxyGrant; + expect(JSON.stringify([...request.headers])).not.toContain(grant); + expect(request.body).not.toContain(grant); + } + } finally { + await proxy.stop(true); + } + } + }, + 60_000, +); + +test("Given Host version metadata, When testing a Go key without a model, Then the default inference probe uses that deployment", async () => { + const { bindings } = await setupFixture({ vendorId: "opencode-go" }); + bindings.CF_VERSION_METADATA = { id: "go-host-probe", tag: "", timestamp: "" }; + const viewer = await getAccountViewer(bindings.DB, PUBLIC_API_TEST_IDS.ownerAccount as AccountId); + if (viewer === null) throw new Error("Missing fixture viewer"); + const captured = captureUpstreamFetch(() => + Response.json({ choices: [{ message: { content: "pong" } }] }), + ); + const result = await testVendorCredential(bindings, viewer, { + projectId: PROJECT_ID, + apiKey: "probe-key", + vendorId: "opencode-go", + }); + expect(result.ok).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0]?.method).toBe("POST"); + expect(JSON.parse(captured[0]?.body ?? "{}")).toMatchObject({ model: "deepseek-v4.1-flash" }); + expect(captured[0]?.headers.get("user-agent")).toBe("mosoo/go-host-probe"); +}); + +test.each([401, 429, 500])( + "Go upstream %s errors do not expose the provider key", + async (status) => { + const { bindings } = await setupFixture({ vendorId: "opencode-go" }); + captureUpstreamFetch( + () => new Response(`Rejected Bearer ${UPSTREAM_API_KEY}; oc_sk_echoed-secret`, { status }), + ); + const grant = await createLlmProxyGrant(bindings, { + modelProtocol: "openai-chat-completions", + modelId: "deepseek-v4.1-flash", + }); + const response = await dispatch( + bindings, + llmProxyRequest("/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${grant}` }, + body: JSON.stringify({ model: "deepseek-v4.1-flash" }), + }), + ); + expect(response.status).toBe(status); + const body = await response.text(); + expect(body).not.toContain(UPSTREAM_API_KEY); + expect(body).not.toContain("oc_sk_echoed-secret"); + }, +); + +test.each(["chunked", "oversized"] as const)( + "Go %s error responses are bounded and sanitized", + async (kind) => { + const { bindings } = await setupFixture({ vendorId: "opencode-go" }); + captureUpstreamFetch( + () => + new Response( + new ReadableStream({ + start(controller) { + for (const chunk of kind === "chunked" + ? ["sk-real-", "upstream-key"] + : ["x".repeat(65536), UPSTREAM_API_KEY]) + controller.enqueue(new TextEncoder().encode(chunk)); + controller.close(); + }, + }), + { status: 500 }, + ), + ); + const grant = await createLlmProxyGrant(bindings, { + modelProtocol: "openai-chat-completions", + modelId: "deepseek-v4.1-flash", + }); + const response = await dispatch( + bindings, + llmProxyRequest("/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${grant}` }, + body: JSON.stringify({ model: "deepseek-v4.1-flash" }), + }), + ); + expect(response.status).toBe(500); + const body = await response.text(); + expect(body).not.toContain(UPSTREAM_API_KEY); + if (kind === "oversized") + expect(body).toBe(JSON.stringify({ error: "Provider request failed." })); + }, +); + +test.each([304, 400])( + "Go %s errors redact headers and preserve bodyless responses", + async (status) => { + const { bindings } = await setupFixture({ vendorId: "opencode-go" }); + captureUpstreamFetch( + () => + new Response(status === 304 ? null : "failure", { + status, + headers: { + location: `https://example.com/${UPSTREAM_API_KEY}`, + "x-provider-error": UPSTREAM_API_KEY, + "content-encoding": "identity", + "content-md5": "old-digest", + etag: "old-etag", + digest: "old-digest", + "content-digest": "old-digest", + }, + }), + ); + const grant = await createLlmProxyGrant(bindings, { + modelProtocol: "openai-chat-completions", + modelId: "deepseek-v4.1-flash", + }); + const response = await dispatch( + bindings, + llmProxyRequest("/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${grant}` }, + body: JSON.stringify({ model: "deepseek-v4.1-flash" }), + }), + ); + expect(response.status).toBe(status); + expect(JSON.stringify([...response.headers])).not.toContain(UPSTREAM_API_KEY); + for (const header of ["content-encoding", "content-md5", "etag", "digest", "content-digest"]) + expect(response.headers.has(header)).toBe(false); + if (status === 304) expect(response.body).toBeNull(); + }, +); diff --git a/apps/api/tests/helpers/pi-proxy-protocol-fixture.ts b/apps/api/tests/helpers/pi-proxy-protocol-fixture.ts index 50c9d0b9..2b2a80f1 100644 --- a/apps/api/tests/helpers/pi-proxy-protocol-fixture.ts +++ b/apps/api/tests/helpers/pi-proxy-protocol-fixture.ts @@ -61,7 +61,17 @@ export function piProxyProtocolResponse(protocol: PresetModelProtocol, tool: boo model: "pi-test", choices: [{ index: 0, delta: value, finish_reason: finish }], }); - return sse([chunk(delta, null), chunk({}, tool ? "tool_calls" : "stop")]); + return sse([ + chunk(delta, null), + chunk({}, tool ? "tool_calls" : "stop"), + { + id: "chat_proxy", + object: "chat.completion.chunk", + model: "pi-test", + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }, + }, + ]); } case "anthropic-messages": return sse([ @@ -326,6 +336,10 @@ export async function runPiProxyTurn( /** Real pinned OpenCode CLI using Driver-rendered provider configuration. */ export async function runOpenCodeProxyTurn( modelConnection: DriverModelConnection, + input: Pick = { + provider: "openai-compatible", + model: "pi-test", + }, ): Promise[]> { const home = await mkdtemp(join(tmpdir(), "mosoo-opencode-proxy-")); const executable = fileURLToPath( @@ -340,7 +354,7 @@ export async function runOpenCodeProxyTurn( "--format", "json", "--model", - "openai-compatible/pi-test", + `${input.provider}/${input.model}`, "--title", "Protocol integration fixture", "Run the provided bash tool and then finish.", @@ -351,8 +365,7 @@ export async function runOpenCodeProxyTurn( PATH: process.env["PATH"] ?? "", OPENCODE_API_KEY: modelConnection.proxyGrant, OPENCODE_CONFIG_CONTENT: buildOpenCodeModelConfig({ - model: "pi-test", - provider: "openai-compatible", + ...input, modelConnection, }), HOME: home, diff --git a/apps/api/tests/runtime-final-output-ingestion.test.ts b/apps/api/tests/runtime-final-output-ingestion.test.ts index 9f84a3a9..24bdaa8a 100644 --- a/apps/api/tests/runtime-final-output-ingestion.test.ts +++ b/apps/api/tests/runtime-final-output-ingestion.test.ts @@ -711,6 +711,43 @@ describe("runtime final output ingestion", () => { }); }); + test("persists native step totals before committing the checkpoint and Run", async () => { + const { bindings, database } = await createCheckpointCompletionFixture(); + await pushFreshController(bindings, [ + runtimeEvent({ + kind: "usage.updated", + payload: { + cachedReadTokens: 50, + costAmount: 0.001234, + costCurrency: "USD", + inputTokens: 100, + outputTokens: 20, + source: "native_step_totals", + totalTokens: 170, + usageContract: "anthropic_bucketed", + }, + sourceEventId: "usage:native-step-totals", + }), + ]); + await pushFreshController(bindings, completionEvents()); + expect((await getSessionRunSummary(database, RUN_ID))?.status).toBe("completed"); + expect( + await database + .prepare( + "SELECT status, input_tokens, output_tokens, total_cost_usd_micros FROM session_model_call", + ) + .first(), + ).toEqual({ + status: "completed", + input_tokens: 100, + output_tokens: 20, + total_cost_usd_micros: 1234, + }); + expect( + await database.prepare("SELECT input_tokens, output_tokens FROM usage_event").first(), + ).toEqual({ input_tokens: 150, output_tokens: 20 }); + }); + test("acknowledges a replay mixing new and durable events in submission order", async () => { const { bindings } = await createCheckpointCompletionFixture(); const durableEvents = messageEvents({ diff --git a/apps/api/tests/session-process-events.test.ts b/apps/api/tests/session-process-events.test.ts index 389f6732..e092592c 100644 --- a/apps/api/tests/session-process-events.test.ts +++ b/apps/api/tests/session-process-events.test.ts @@ -250,6 +250,37 @@ async function insertSessionProcessEvent( } describe("session process event projection", () => { + test("counts the latest usage snapshot per Run and model call", async () => { + const database = createProcessEventQueryDatabase(); + const snapshots = [ + { runId: "run-1", callId: null, tokens: 1916 }, + { runId: "run-1", callId: "\t\n", tokens: 2000 }, + { runId: "run-1", callId: null, tokens: 3865 }, + { runId: "run-1", callId: "\t second-call \u00a0", tokens: 5 }, + { runId: "run-1", callId: "second-call", tokens: 10 }, + { runId: "run-2", callId: null, tokens: 8 }, + ]; + for (const [index, snapshot] of snapshots.entries()) { + await insertSessionProcessEvent(database, { + canonicalEventJson: JSON.stringify({ + kind: "usage.updated", + payload: { callId: snapshot.callId, source: "session_update" }, + }), + id: `usage-${index}`, + processType: "usage.updated", + runId: snapshot.runId, + seq: index + 1, + tokens: snapshot.tokens, + }); + } + const events = await getThreadSessionProcessEvents(database, VIEWER, { + projectId: PROJECT_ID, + sessionId: SESSION_ID, + }); + expect(events.map((event) => event.tokens)).toEqual([null, null, 3865, null, 10, 8]); + expect(events.reduce((total, event) => total + (event.tokens ?? 0), 0)).toBe(3883); + }); + test("reads canonical thought chunks and marks a window missing the first chunk as truncated", async () => { const database = createProcessEventQueryDatabase(); for (const [index, content] of ["Saved", " thought"].entries()) { diff --git a/apps/api/tests/vendor-credential-probe.test.ts b/apps/api/tests/vendor-credential-probe.test.ts index 7c42c619..c84bfad4 100644 --- a/apps/api/tests/vendor-credential-probe.test.ts +++ b/apps/api/tests/vendor-credential-probe.test.ts @@ -4,6 +4,7 @@ import type { PresetModelProtocol } from "@mosoo/contracts/models"; import { readVendorProbeBaseHost, + readVendorProbeErrorCode, toVendorProbeEndpointUrl, validateVendorProbeBaseUrl, vendorProbeModelListIncludes, @@ -11,6 +12,21 @@ import { import { probeVendorCredential } from "../src/modules/vendor-credentials/application/vendor-credential-test"; describe("vendor credential probe", () => { + test.each(["json", "text"] as const)( + "Given an echoed Go key, When reading a %s probe error, Then the key is redacted", + async (format) => { + const message = "Invalid API key oc_sk_test-secret_123; replace sk-legacy-secret"; + const response = + format === "json" + ? Response.json({ error: { message } }, { status: 401 }) + : new Response(message, { status: 401 }); + + expect(await readVendorProbeErrorCode(response)).toBe( + "http_401: Invalid API key oc_sk_***; replace sk-***", + ); + }, + ); + test("builds v1 endpoint URLs from vendor API bases", () => { expect(toVendorProbeEndpointUrl("https://api.example.com", "models")).toBe( "https://api.example.com/v1/models", @@ -317,3 +333,78 @@ describe("vendor credential probe", () => { }); } }); + +test("Given a Go key, When testing a model, Then the probe identifies Mosoo and uses Go auth", async () => { + const originalFetch = globalThis.fetch; + const requests: Request[] = []; + globalThis.fetch = async (url, init) => { + requests.push(new Request(url, init)); + return Response.json({ type: "message", content: [{ type: "text", text: "pong" }] }); + }; + try { + const result = await probeVendorCredential({ + apiKey: "probe-key", + modelId: "minimax-m2.7", + modelProtocol: "anthropic-messages", + vendorId: "opencode-go", + versionMetadata: { id: "go-probe-release" }, + verifyModelProtocol: true, + }); + expect(result.ok).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe("https://opencode.ai/zen/go/v1/messages"); + expect(requests[0].headers.get("authorization")).toBe("Bearer probe-key"); + expect(requests[0].headers.get("user-agent")).toBe("mosoo/go-probe-release"); + expect(requests[0].headers.get("x-opencode-session")).toMatch(/^[a-f0-9-]{36}$/); + expect(requests[0].headers.get("anthropic-version")).toBe("2023-06-01"); + expect(requests[0].headers.has("x-api-key")).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test.each([true, false])( + "Go credential tests verify inference authentication when the public model list is accessible (valid=%s)", + async (validKey) => { + const originalFetch = globalThis.fetch; + const requests: Request[] = []; + globalThis.fetch = async (url, init) => { + const request = new Request(url, init); + requests.push(request); + if (request.method === "GET") { + return Response.json({ data: [{ id: "deepseek-v4.1-flash" }] }); + } + return validKey + ? Response.json({ choices: [{ message: { content: "pong" } }] }) + : Response.json({ error: { message: "Invalid API key" } }, { status: 401 }); + }; + try { + const result = await probeVendorCredential({ apiKey: "probe-key", vendorId: "opencode-go" }); + expect(result.ok).toBe(validKey); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(await requests[0]?.json()).toMatchObject({ + model: "deepseek-v4.1-flash", + max_tokens: 1, + }); + if (!validKey) expect(result.errorCode).toStartWith("http_401"); + } finally { + globalThis.fetch = originalFetch; + } + }, +); + +test.each([402, 429, 404])( + "Go probe preserves upstream HTTP %s instead of labeling it as a bad key", + async (status) => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({ error: { message: "Unavailable" } }, { status }); + try { + const result = await probeVendorCredential({ apiKey: "probe-key", vendorId: "opencode-go" }); + expect(result.ok).toBe(false); + expect(result.errorCode).toStartWith(`http_${status}`); + } finally { + globalThis.fetch = originalFetch; + } + }, +); diff --git a/apps/api/tests/worker-user-agent.test.ts b/apps/api/tests/worker-user-agent.test.ts new file mode 100644 index 00000000..10ebc79f --- /dev/null +++ b/apps/api/tests/worker-user-agent.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import { getWorkerUserAgent } from "../src/platform/cloudflare/worker-user-agent"; + +test("Given no deployment metadata, When running locally, Then identify a development build", () => { + expect(getWorkerUserAgent()).toBe("mosoo/dev"); + expect(getWorkerUserAgent({ id: "" })).toBe("mosoo/dev"); +}); + +test("Given a new Worker deployment, When constructing client identity, Then use its version ID", () => { + expect(getWorkerUserAgent({ id: "release-a" })).toBe("mosoo/release-a"); + expect(getWorkerUserAgent({ id: "release-b" })).toBe("mosoo/release-b"); +}); + +test("Given any API environment, When deployed, Then Cloudflare injects version metadata", () => { + const config = Bun.TOML.parse(readFileSync(new URL("../wrangler.toml", import.meta.url), "utf8")); + const environments = config.env as Record>; + for (const environment of [config, environments.stage, environments.prod]) { + expect(environment?.version_metadata).toEqual({ binding: "CF_VERSION_METADATA" }); + } +}); diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index b4592ed2..b40d95a2 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -4,6 +4,9 @@ compatibility_date = "2026-06-01" compatibility_flags = ["nodejs_compat"] keep_vars = true +[version_metadata] +binding = "CF_VERSION_METADATA" + [observability] enabled = true @@ -157,6 +160,9 @@ workers_dev = true # Stage mirrors production behavior and topology but uses isolated resources # and synthetic data behind its workers.dev origin. +[env.stage.version_metadata] +binding = "CF_VERSION_METADATA" + [env.stage.observability] enabled = true @@ -308,6 +314,9 @@ dead_letter_queue = "mosoo-stage-api-command-dlq" name = "mosoo-api-prod" workers_dev = false +[env.prod.version_metadata] +binding = "CF_VERSION_METADATA" + # Named envs do not inherit the top-level [observability] block, so prod had no # queryable Workers Logs (only traces). Enable logs so structured log lines # (e.g. "closed before ready", driver/ready/socket events) are retrievable. diff --git a/apps/web/src/routes/providers/providers-tab.tsx b/apps/web/src/routes/providers/providers-tab.tsx index 525fee66..8ac209e5 100644 --- a/apps/web/src/routes/providers/providers-tab.tsx +++ b/apps/web/src/routes/providers/providers-tab.tsx @@ -126,7 +126,7 @@ function apiKeyPlaceholder( t: ReturnType["t"], ): string { if (form.id === null) { - return "sk-..."; + return form.vendorId === "opencode-go" ? "oc_sk_..." : "sk-..."; } return form.maskedApiKey diff --git a/apps/web/tests/runtime-default.test.ts b/apps/web/tests/runtime-default.test.ts index 69ceaa9f..a8f082a9 100644 --- a/apps/web/tests/runtime-default.test.ts +++ b/apps/web/tests/runtime-default.test.ts @@ -159,3 +159,16 @@ describe("default agent runtime", () => { }); }); }); + +test("Given only a Go credential, When launching an Agent, Then OpenCode uses a Go model", () => { + expect(resolveDefaultAgentRuntime([credential("opencode-go")])).toEqual({ + model: "deepseek-v4.1-flash", + provider: "opencode-go", + runtimeId: "acp-fallback", + }); + expect(resolveDefaultAgentRuntime([credential("opencode-go")], "pi")).toEqual({ + model: "deepseek-v4.1-flash", + provider: "opencode-go", + runtimeId: "pi", + }); +}); diff --git a/docs/architecture.md b/docs/architecture.md index f9f73536..83d79853 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,6 +60,7 @@ flowchart LR It adds a delegation token carrying the end-user `userId` only when the Session has one. - **Model protocol:** one protocol is resolved from the preset provider/model or from the custom credential's declared protocol, checked against the runtime, and frozen into the Session plan. Continuation fails if the credential's protocol later changes. The Host supplies the typed model connection and protocol; each Driver adapter renders its native configuration from the frozen execution, with Pi preserving metadata from its installed model catalog. + OpenCode and Pi receive no native additional-directory requests; their Session workspace and runtime home remain in the execution context. - **Network:** an Environment is `full` (container defaults) or `limited`: no direct internet, and a deny-by-default HTTP(S) allowlist of the Environment's hosts, the API control origin and the R2 endpoint, checked on every outbound request including each redirect hop. The policy is applied before the container starts and is fixed for the subject's lifetime: a different policy fails closed. A `limited` Environment rejects proxy variables. Where `limited` cannot be enforced (local workerd), startup fails closed. ## 5. Runtime: subjects, Driver and checkpoints @@ -122,7 +123,7 @@ flowchart LR Snapshot delivery restarts its read if replay frames are discarded before it finishes. Driver ingestion validates thought offsets, completion and byte totals from persisted metadata and the last content chunk, keeping full thought bodies out of its live state. - **Background work:** an `api_command` row is written to D1 before delivery (for Run dispatch, in the same batch as the Run). Queues deliver it at least once, consumers claim it idempotently, and the one-minute cron redrives commands that were never delivered or whose consumer stopped, then starts maintenance. -- **Usage:** each model call writes a `session_model_call` row and its `usage_event` in one batch. A daily rollup folds `usage_event` rows older than `DETAIL_RETENTION_DAYS` into `usage_daily_rollup`. Costs are estimates from a recorded price snapshot (`pricing_status` may be `unknown`); there is no billing or settlement. +- **Usage:** each model call writes a `session_model_call` row and its `usage_event` in one batch. OpenCode reports totals from native steps when exporting its checkpoint; the Host records them before Run completion. Replay totals count the latest usage snapshot per Run and model call, matching the ledger's replacement rule. A daily rollup folds `usage_event` rows older than `DETAIL_RETENTION_DAYS` into `usage_daily_rollup`. Costs are estimates from a recorded price snapshot (`pricing_status` may be `unknown`); there is no billing or settlement. - **Logs and traces:** Vestig structured logs and wide events, W3C `traceparent` across HTTP requests and the Driver socket, and Workers Logs and Traces in every environment. There is no blanket OpenTelemetry instrumentation. - **Status page (cross-repo contract):** production names `mosoo-website-prod` as its Tail consumer, which builds mosoo.ai/status from Worker invocation outcomes and two log events. `session.run.terminal` is logged when a Run's terminal transition commits and carries `runId`, `runtimeId`, `sessionType`, `status`, `durationMs` and `errorCode`; its log timestamp is the Run's completion time. Repair logs it again with the same payload until the Run's terminal history event exists, so consumers deduplicate by `runId`. `runtime.sandbox.egress.http_error` (`httpStatus`) marks a Sandbox's outbound response of 500 or more, including allowlist denials, so it is not counted as an API failure. Keep these names and fields, and never add prompts, model output, user IDs or account IDs. - **Container monitor:** `.github/workflows/container-runtime-alert.yml` derives every Sandbox container application from `apps/api/wrangler.toml`, fails when one is missing from Cloudflare, and opens a GitHub issue when production or stage holds too many containers or one that runs too long. It never stops containers. diff --git a/docs/prd/runtime-catalog.md b/docs/prd/runtime-catalog.md index 263c715b..4e621bc0 100644 --- a/docs/prd/runtime-catalog.md +++ b/docs/prd/runtime-catalog.md @@ -9,6 +9,8 @@ Which runtimes and models a Project can run, and how providers and model protoco - Every provider/model endpoint speaks one model protocol: Chat Completions, Responses, Anthropic Messages or Google Gemini. Preset models use the protocol in the catalog. A custom provider declares one, and new custom credentials default to Chat Completions. - OpenCode and Pi accept all four protocols, OpenAI Runtime only Responses, and Claude Agent SDK only Anthropic Messages for Anthropic models. A runtime and protocol pair that does not match is rejected before any execution. - Custom providers are `openai-compatible` in the API. When a Project's only usable keys are custom, a new Agent in the console defaults to OpenCode; Pi or OpenAI Runtime runs custom models only when chosen explicitly. Pi's default is an incomplete placeholder until a preset model or a model declared on a custom credential is selected. +- OpenCode Go is a separate provider from OpenCode Zen. Add a Go API key in Providers and select a Go model with OpenCode or Pi. The catalog exposes only Chat Completions Go presets; Messages and Responses models require real-provider acceptance before admission. [OpenCode's Go documentation](https://opencode.ai/docs/go/) defines the upstream endpoints and subscription limits. Mosoo does not sell or manage the subscription. +- Go requests identify Mosoo with the current Cloudflare Worker version ID (`mosoo/dev` for local callers without deployment metadata) and use the Host Session ID for routing and caching, including after Driver replacement. Manual credential tests use a separate temporary conversation ID and a minimal inference request, which consumes subscription usage; the public model list does not verify a key. Provider keys stay behind the Host proxy. - Pi preserves the installed native catalog’s capabilities, limits and prices for an exact provider and model match, including through the model proxy. - Pi’s advanced settings accept a thinking level; activation rejects levels that the selected native model cannot use. Omitting the setting keeps Pi’s default. - Publishing does not lock an Agent's runtime or model. @@ -21,3 +23,5 @@ Which runtimes and models a Project can run, and how providers and model protoco - A matching protocol does not prove that an endpoint supports the streaming and tool calls a runtime needs. - Changing a custom credential's protocol breaks the Sessions that use it: their model calls are refused, and continuing them fails with a request to restore the protocol or start a new Session. A native conversation never switches protocols. - A custom credential saved without a protocol keeps its old per-runtime route (Responses on OpenAI Runtime, Chat Completions elsewhere) until one is declared. A declared protocol cannot be cleared. + +Go model usage is subject to the external subscription and its configured overage policy. Mosoo's Cost view estimates model usage; it does not represent remaining Go quota or subscription charges. diff --git a/e2e/cases/ui/opencode-go.spec.ts b/e2e/cases/ui/opencode-go.spec.ts new file mode 100644 index 00000000..0d26c3d9 --- /dev/null +++ b/e2e/cases/ui/opencode-go.spec.ts @@ -0,0 +1,42 @@ +import { mkdirSync } from "node:fs"; + +import { expect, test } from "@playwright/test"; + +import { installConsoleFixtures } from "../../lib/console-fixtures"; + +for (const width of [1440, 390]) { + test(`Go credentials stay separate from Zen at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await installConsoleFixtures(page); + await page.goto("/providers"); + + const go = page.locator("section").filter({ + has: page.getByRole("heading", { name: "OpenCode Go", exact: true }), + }); + await expect(go).toBeVisible(); + await expect(page.getByRole("heading", { name: "OpenCode Zen", exact: true })).toBeVisible(); + const output = ".tmp/e2e/opencode-go"; + mkdirSync(output, { recursive: true }); + await go.screenshot({ path: `${output}/provider-${width}.png` }); + await go.getByRole("button", { name: "Add key", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "Add OpenCode Go key" })).toBeVisible(); + await expect(dialog.getByLabel("API key", { exact: true })).toBeVisible(); + await expect(dialog.getByLabel("API key", { exact: true })).toHaveAttribute( + "placeholder", + "oc_sk_...", + ); + await expect(dialog.getByLabel("Base URL", { exact: true })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Save", exact: true })).toBeVisible(); + await dialog.screenshot({ path: `${output}/dialog-${width}.png` }); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + const zen = page.locator("section").filter({ + has: page.getByRole("heading", { name: "OpenCode Zen", exact: true }), + }); + await zen.getByRole("button", { name: "Add key", exact: true }).click(); + await expect(dialog.getByLabel("API key", { exact: true })).toHaveAttribute( + "placeholder", + "sk-...", + ); + }); +} diff --git a/pkgs/ag-ui-session/src/live-state.ts b/pkgs/ag-ui-session/src/live-state.ts index b1788efc..a7bf5d79 100644 --- a/pkgs/ag-ui-session/src/live-state.ts +++ b/pkgs/ag-ui-session/src/live-state.ts @@ -114,7 +114,7 @@ export interface SessionUsageSummary { outputTokens?: number | null; provider?: string | null; size?: number | null; - source: "prompt_response" | "session_update"; + source: "native_step_totals" | "prompt_response" | "session_update"; thoughtTokens?: number | null; totalTokens?: number | null; usageContract?: diff --git a/pkgs/ag-ui-session/src/session-live-state-schema.ts b/pkgs/ag-ui-session/src/session-live-state-schema.ts index e628cc8a..1c842870 100644 --- a/pkgs/ag-ui-session/src/session-live-state-schema.ts +++ b/pkgs/ag-ui-session/src/session-live-state-schema.ts @@ -74,7 +74,7 @@ export const SessionUsageSummarySchema = type({ "outputTokens?": "number.safe | null", "provider?": "string | null", "size?": "number.safe | null", - source: '"prompt_response" | "session_update"', + source: '"native_step_totals" | "prompt_response" | "session_update"', "thoughtTokens?": "number.safe | null", "totalTokens?": "number.safe | null", "usageContract?": diff --git a/pkgs/runtime-catalog/src/catalog.ts b/pkgs/runtime-catalog/src/catalog.ts index 6a0d5d5d..de770ee7 100644 --- a/pkgs/runtime-catalog/src/catalog.ts +++ b/pkgs/runtime-catalog/src/catalog.ts @@ -184,6 +184,16 @@ const VENDOR_OPENCODE: RuntimeCatalogVendor = { vendorId: "opencode", }; +const VENDOR_OPENCODE_GO: RuntimeCatalogVendor = { + apiKeyEnvVar: "OPENCODE_GO_API_KEY", + authHeader: BEARER_AUTH, + defaultApiBase: "https://opencode.ai/zen/go/v1", + iconKey: "opencode", + label: "OpenCode Go", + openCodeProvider: { name: "OpenCode Go", npmPackage: "@ai-sdk/openai-compatible" }, + vendorId: "opencode-go", +}; + export const VENDORS: readonly RuntimeCatalogVendor[] = [ VENDOR_ANTHROPIC, VENDOR_OPENAI, @@ -195,6 +205,7 @@ export const VENDORS: readonly RuntimeCatalogVendor[] = [ VENDOR_ZHIPU, VENDOR_MINIMAX, VENDOR_OPENCODE, + VENDOR_OPENCODE_GO, ]; export const MODEL_DEFAULTS: Readonly> = { @@ -204,6 +215,7 @@ export const MODEL_DEFAULTS: Readonly> = { kimi: "kimi-k2.6", minimax: "MiniMax-M3", opencode: "deepseek-v4-pro", + "opencode-go": "deepseek-v4.1-flash", openai: "gpt-5.5", qwen: "qwen3.7-plus", zhipu: "glm-4.7", @@ -402,6 +414,36 @@ export const PRESET_MODELS: readonly RuntimeCatalogPresetModel[] = [ protocol: "google-gemini", vendor: VENDOR_OPENCODE, }, + { + displayName: "DeepSeek V4.1 Flash", + modelId: "deepseek-v4.1-flash", + protocol: "openai-chat-completions", + vendor: VENDOR_OPENCODE_GO, + }, + { + displayName: "DeepSeek V4 Pro", + modelId: "deepseek-v4-pro", + protocol: "openai-chat-completions", + vendor: VENDOR_OPENCODE_GO, + }, + { + displayName: "GLM 5.2", + modelId: "glm-5.2", + protocol: "openai-chat-completions", + vendor: VENDOR_OPENCODE_GO, + }, + { + displayName: "Kimi K2.6", + modelId: "kimi-k2.6", + protocol: "openai-chat-completions", + vendor: VENDOR_OPENCODE_GO, + }, + { + displayName: "Kimi K2.7 Code", + modelId: "kimi-k2.7-code", + protocol: "openai-chat-completions", + vendor: VENDOR_OPENCODE_GO, + }, ]; const ALL_MODEL_PROTOCOLS: readonly PresetModelProtocol[] = [ @@ -421,6 +463,7 @@ const MULTI_PROVIDER_VENDORS: readonly RuntimeCatalogVendor[] = [ VENDOR_ZHIPU, VENDOR_MINIMAX, VENDOR_OPENCODE, + VENDOR_OPENCODE_GO, ]; export const RUNTIMES: readonly RuntimeCatalogRuntime[] = [ diff --git a/pkgs/runtime-catalog/tests/opencode-go.test.ts b/pkgs/runtime-catalog/tests/opencode-go.test.ts new file mode 100644 index 00000000..c6f72732 --- /dev/null +++ b/pkgs/runtime-catalog/tests/opencode-go.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; + +import { + PUBLIC_VENDORS, + getDefaultModelIdForVendor, + getVendor, + resolveRuntimeModelProtocol, + getPresetModel, +} from "@mosoo/runtime-catalog"; + +test("Given a Go subscription, When choosing a provider, Then Go stays distinct from Zen", () => { + expect(PUBLIC_VENDORS.find((vendor) => vendor.vendorId === "opencode-go")).toMatchObject({ + label: "OpenCode Go", + iconKey: "opencode", + defaultApiBase: "https://opencode.ai/zen/go/v1", + apiKeyEnvVar: "OPENCODE_GO_API_KEY", + }); + expect(getVendor("opencode")?.defaultApiBase).toBe("https://opencode.ai/zen/v1"); + expect(getDefaultModelIdForVendor("opencode-go")).toBe("deepseek-v4.1-flash"); +}); + +test.each(["acp-fallback", "pi"])( + "Given Go models, When %s admits them, Then their official protocols are preserved", + (runtimeId) => { + for (const [modelId, modelProtocol] of [ + ["deepseek-v4.1-flash", "openai-chat-completions"], + ["deepseek-v4-pro", "openai-chat-completions"], + ["kimi-k2.6", "openai-chat-completions"], + ]) { + expect( + resolveRuntimeModelProtocol({ runtimeId, vendorId: "opencode-go", modelId: modelId }), + ).toEqual({ ok: true, modelProtocol }); + } + expect( + resolveRuntimeModelProtocol({ + runtimeId, + vendorId: "opencode-go", + modelId: "gemini-3.5-flash", + }), + ).toMatchObject({ ok: false, code: "model-unknown" }); + }, +); + +test("Go only admits Chat Completions presets until other protocols have real acceptance", () => { + for (const modelId of ["minimax-m3", "minimax-m2.7", "qwen3.7-plus", "gpt-5.6-luna"]) { + expect(getPresetModel({ vendorId: "opencode-go", modelId })).toBeNull(); + } +}); diff --git a/pkgs/runtime-events/tests/runtime-protocol-bridge.test.ts b/pkgs/runtime-events/tests/runtime-protocol-bridge.test.ts index 1e29142d..f22c78c2 100644 --- a/pkgs/runtime-events/tests/runtime-protocol-bridge.test.ts +++ b/pkgs/runtime-events/tests/runtime-protocol-bridge.test.ts @@ -236,6 +236,34 @@ describe("Driver protocol projection bridge", () => { ).toThrow(); }); + test("preserves OpenCode native step usage through Host validation and live projection", () => { + const usage = { + cachedReadTokens: 2_000, + costAmount: 0.001234, + costCurrency: "USD", + inputTokens: 8_000, + outputTokens: 42, + source: "native_step_totals", + totalTokens: 10_042, + usageContract: "anthropic_bucketed", + }; + const event = parseRuntimeEventEnvelope( + envelope("usage.updated", usage, { runtimeId: "acp-fallback" }), + ); + expect(event.payload).toEqual(usage); + const initial = createInitialSessionLiveState({ + sessionId: PLATFORM_ID_FIXTURES.session, + title: null, + viewerId: "viewer", + }); + const state = applyAgUiEventsToSessionLiveState( + initial, + projectRuntimeEventToAgUiSessionEvents(event), + ); + expect(state.usage).toEqual(usage); + expect(createProcessDraftFromRuntimeEvent(event).tokens).toBe(usage.totalTokens); + }); + test("does not expose internal driver events to participants", () => { const event = parseRuntimeEventEnvelope( envelope(