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
68 changes: 64 additions & 4 deletions src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,60 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/c

let _connectedProviders: Set<string> = new Set();
let _v2Client: OpencodeClient | undefined;
const _clientBaseUrls = new WeakMap<OpencodeClient, string>();
const REDACTED = "[REDACTED]";
const SENSITIVE_KEY_PATTERN =
/(authorization|cookie|token|password|passwd|secret|apikey|privatekey)/;

function getClientBaseUrl(client: OpencodeClient): string | undefined {
return _clientBaseUrls.get(client);
}

function sanitizeBaseUrl(baseUrl: string): string {
try {
const url = new URL(baseUrl);
return `${url.origin}${url.pathname}`;
} catch {
return baseUrl;
}
}

function isSensitiveKey(key: string): boolean {
const normalizedKey = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
return normalizedKey.length > 0 && SENSITIVE_KEY_PATTERN.test(normalizedKey);
}

function summarizeValue(value: unknown, maxLength = 300): string {
const seen = new WeakSet<object>();
const replacer = (key: string, currentValue: unknown): unknown => {
if (isSensitiveKey(key)) {
return REDACTED;
}
if (currentValue instanceof Error) {
return {
name: currentValue.name,
message: currentValue.message,
};
}
if (typeof currentValue === "object" && currentValue !== null) {
if (seen.has(currentValue)) {
return "[Circular]";
}
seen.add(currentValue);
}
return currentValue;
};

try {
const json = JSON.stringify(value, replacer);
if (json === undefined) {
return String(value);
}
return json.length > maxLength ? `${json.slice(0, maxLength)}…` : json;
} catch (error) {
return `[unserializable: ${error instanceof Error ? error.message : String(error)}]`;
}
}

export function setConnectedProviders(providers: string[]): void {
_connectedProviders = new Set(providers);
Expand All @@ -34,7 +88,9 @@ export function getV2Client(): OpencodeClient | undefined {

export function createV2Client(serverUrl: URL | string): OpencodeClient {
const baseUrl = typeof serverUrl === "string" ? serverUrl : serverUrl.toString();
return createOpencodeClient({ baseUrl });
const client = createOpencodeClient({ baseUrl });
_clientBaseUrls.set(client, baseUrl);
return client;
}

export interface StructuredOutputOptions<T> {
Expand Down Expand Up @@ -74,9 +130,13 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
});
const sessionID = (created as { data?: { id?: string } })?.data?.id;
if (!sessionID) {
throw new Error(
"opencode-mem: session.create returned no session id; cannot generate structured output"
);
const diagnostics = ["opencode-mem: session.create returned no session id"];
const baseUrl = getClientBaseUrl(client);
if (baseUrl) {
diagnostics.push(`baseUrl=${sanitizeBaseUrl(baseUrl)}`);
}
diagnostics.push(`response=${summarizeValue(created)}`);
throw new Error(`${diagnostics.join("; ")}; cannot generate structured output`);
}

try {
Expand Down
97 changes: 92 additions & 5 deletions tests/opencode-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,22 +225,109 @@ describe("generateStructuredOutput", () => {
it("rejects when session.create returns no id", async () => {
mock = installFetchMock((call) => {
if (call.method === "POST" && call.url.endsWith("/session")) {
return { body: {} };
return {
body: {
error: {},
request: {
timeout: false,
authorization: "Bearer super-secret-auth",
token: "top-level-token",
cookie: "session=super-cookie",
"set-cookie": "refresh=super-set-cookie",
accessToken: "access-token-value",
refreshToken: "refresh-token-value",
},
nested: {
secret: "nested-secret-value",
password: "nested-password-value",
clientSecret: "nested-client-secret",
secretKey: "nested-secret-key",
privateKey: "nested-private-key",
"private-key": "nested-private-key-dashed",
},
},
};
}
throw new Error(`unexpected fetch: ${call.method} ${call.url}`);
});

const client = createV2Client("http://user:pass@127.0.0.1:9999/api?v=1#frag");
try {
await generateStructuredOutput({
client,
providerID: "anthropic",
modelID: "claude-haiku-4-5",
systemPrompt: "s",
userPrompt: "u",
schema,
});
throw new Error("expected generateStructuredOutput to throw");
} catch (error) {
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toMatch(/session\.create returned no session id/);
expect(message).toContain("http://127.0.0.1:9999/api");
expect(message).not.toContain("user:pass");
expect(message).not.toContain("?v=1");
expect(message).not.toContain("#frag");
expect(message).toContain('"error":{}');
expect(message).toContain('"authorization":"[REDACTED]"');
expect(message).toContain('"token":"[REDACTED]"');
expect(message).toContain('"cookie":"[REDACTED]"');
expect(message).toContain('"set-cookie":"[REDACTED]"');
expect(message).toContain('"accessToken":"[REDACTED]"');
expect(message).toContain('"refreshToken":"[REDACTED]"');
expect(message).toContain('"secret":"[REDACTED]"');
expect(message).toContain('"password":"[REDACTED]"');
expect(message).toContain('"clientSecret":"[REDACTED]"');
expect(message).not.toContain("super-secret-auth");
expect(message).not.toContain("top-level-token");
expect(message).not.toContain("super-cookie");
expect(message).not.toContain("super-set-cookie");
expect(message).not.toContain("access-token-value");
expect(message).not.toContain("refresh-token-value");
expect(message).not.toContain("nested-secret-value");
expect(message).not.toContain("nested-password-value");
expect(message).not.toContain("nested-client-secret");
expect(message).not.toContain("nested-secret-key");
expect(message).not.toContain("nested-private-key");
expect(message).not.toContain("nested-private-key-dashed");
expect(message).toContain("[REDACTED]");
}
});

it("redacts private key variants in session.create diagnostics", async () => {
mock = installFetchMock((call) => {
if (call.method === "POST" && call.url.endsWith("/session")) {
return {
body: {
privateKey: "top-level-private-key",
"private-key": "dashed-private-key",
},
};
}
throw new Error(`unexpected fetch: ${call.method} ${call.url}`);
});

const client = createV2Client("http://127.0.0.1:9999");
await expect(
generateStructuredOutput({
try {
await generateStructuredOutput({
client,
providerID: "anthropic",
modelID: "claude-haiku-4-5",
systemPrompt: "s",
userPrompt: "u",
schema,
})
).rejects.toThrow(/session\.create returned no session id/);
});
throw new Error("expected generateStructuredOutput to throw");
} catch (error) {
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain('"privateKey":"[REDACTED]"');
expect(message).toContain('"private-key":"[REDACTED]"');
expect(message).not.toContain("top-level-private-key");
expect(message).not.toContain("dashed-private-key");
}
});

it("swallows session.delete failure and still returns success", async () => {
Expand Down