Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions apps/api/src/adapters/http/routes/driver-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -359,6 +373,28 @@ function buildLlmProxyUpstreamHeaders(incoming: Headers, target: RuntimeLlmProxy
return headers;
}

async function readSafeProviderError(response: Response, apiKey: string): Promise<string> {
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,
Expand All @@ -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(
Expand Down Expand Up @@ -574,6 +626,7 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {
try {
target = await resolveRuntimeLlmProxyTarget(c.env, {
credentialId: grant.resourceId,
driverInstanceId: grant.driverInstanceId,
modelProtocol: grant.modelProtocol,
projectId: grant.projectId,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -48,6 +54,7 @@ export async function resolveRuntimeLlmProxyTarget(
bindings: ApiBindings,
input: {
credentialId: VendorCredentialId;
driverInstanceId: DriverInstanceId;
modelProtocol: PresetModelProtocol;
projectId: ProjectId;
},
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async function sha256(value: string): Promise<string> {
}

export async function ensureProvisioningDirectories(
session: ExecutionSessionHandle,
session: Pick<ExecutionSessionHandle, "exec">,
profile: DriverProfileConfig,
): Promise<void> {
const directories = listProvisioningDirectories(profile);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionRunId, Map<string, ProcessEventProjection>>();
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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -156,6 +157,15 @@ export async function readSessionEventRowsAfterSeq(
tool_input_json: sessionEventsTable.toolInputJson,
tool_name: sessionEventsTable.toolName,
tokens: sessionEventsTable.tokens,
usage_key: sql<string | null>`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 }))
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -24,7 +26,17 @@ export function toVendorProbeAuthHeaders(
vendor: RuntimeCatalogVendor,
apiKey: string,
modelProtocol?: PresetModelProtocol | null,
versionMetadata?: Pick<WorkerVersionMetadata, "id">,
): Record<string, string> {
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 };
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -37,6 +42,7 @@ export interface VendorCredentialProbeInput {
timeoutMs?: number;
vendorId: string;
verifyModelProtocol?: boolean;
versionMetadata?: Pick<WorkerVersionMetadata, "id">;
}

function modelProbeRequest(
Expand Down Expand Up @@ -118,6 +124,7 @@ async function probeModelProtocol(input: {
timeoutMs: number;
vendor: RuntimeCatalogVendor;
verifyResponse: boolean;
versionMetadata: Pick<WorkerVersionMetadata, "id"> | undefined;
}): Promise<{ errorCode?: string; ok: boolean }> {
const request = modelProbeRequest(input.modelId, input.modelProtocol);
const url = new URL(input.baseUrl);
Expand All @@ -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",
},
Expand Down Expand Up @@ -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()),
Expand All @@ -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 ??
Expand Down Expand Up @@ -284,7 +301,7 @@ export async function probeVendorCredential(
});
}

if (modelIdRequired && modelId === null) {
if ((modelIdRequired || requiresInferenceProbe) && modelId === null) {
return finishCredentialTest({
baseUrl,
errorCode: "missing_model_id",
Expand All @@ -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,
Expand All @@ -325,7 +343,7 @@ export async function probeVendorCredential(
{
headers: {
Accept: "application/json",
...toVendorProbeAuthHeaders(vendor, apiKey, modelProtocol),
...toVendorProbeAuthHeaders(vendor, apiKey, modelProtocol, input.versionMetadata),
},
method: "GET",
},
Expand All @@ -343,6 +361,7 @@ export async function probeVendorCredential(
if (!ok) {
if (modelProtocol !== null) {
const modelProbe = await probeModelProtocol({
versionMetadata: input.versionMetadata,
apiKey,
baseUrl,
fetchProxy,
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/platform/cloudflare/worker-user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Use the deployed Worker revision; local callers have no deployed version. */
export function getWorkerUserAgent(metadata?: Pick<WorkerVersionMetadata, "id">): string {
return `mosoo/${metadata?.id || "dev"}`;
}
7 changes: 7 additions & 0 deletions apps/api/src/shared/provider-error.ts
Original file line number Diff line number Diff line change
@@ -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***");
}
Loading
Loading