From 4b3fe841bef2738542d9f9422821ec8e5287d9f0 Mon Sep 17 00:00:00 2001 From: TommrraraSnow <178099931+TommrraraSnow@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:51:15 +0800 Subject: [PATCH] fix(picker): recognize credentials stored in DSH --- src/lifecycle.ts | 86 ++++++--- test/lifecycle-credentials.test.ts | 290 +++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 test/lifecycle-credentials.test.ts diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 5579a0a8..7ed76435 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -25,7 +25,11 @@ import { type ResolvedPluginConfig, resolveConfig, } from "./config.ts"; -import { type CordisContext, readEntryOptions } from "./cordis-context.ts"; +import { + type CordisContext, + readCredentialsResolver, + readEntryOptions, +} from "./cordis-context.ts"; import { recordDebug } from "./debug.ts"; import { patchFetch } from "./fetch-patch.ts"; import { resolveGoBaseURL } from "./go-discovery.ts"; @@ -46,7 +50,10 @@ import { import { STORE_NAMESPACE } from "./opencode-store-contract.ts"; import { OpencodeStoreService } from "./opencode-store-service.ts"; import { ModelPurgeService } from "./purge-service.ts"; -import { registerResponsesProvider } from "./responses-provider.ts"; +import { + inheritedCredentialRef, + registerResponsesProvider, +} from "./responses-provider.ts"; import { isServableSdk } from "./responses-routes.ts"; import { createStreamHook } from "./stream-hook.ts"; import type { ActiveTurnState } from "./turn-store.ts"; @@ -396,7 +403,7 @@ const installFetchPatch = ( // Told which planes are keyless, so the picker can offer only what such a // deployment can actually call (the official client's rule). const stopCatalogHiding = hideResponsesRoute(ctx, config, { - hasCredential: hasPlaneCredential, + hasCredential: createPlaneCredentialCheck(ctx), }); // Own the Responses route from here, so the user configures nothing: they // keep the `opencode` provider and key they already have. Best-effort — a @@ -520,30 +527,61 @@ const installModelDiscovery = ( * @param rawConfig - the row config, if any. */ /** - * Whether this plane holds a credential the gateway would accept. - * - * Deliberately synchronous and deliberately generous: a declared env key, a key - * captured from a live request, or an org written by a completed login. The - * SQLite store is not consulted because it is opt-in and off by default, and - * calling a deployment "keyless" when it does hold a credential would hide models - * it can actually use. + * Check credentials on each listing so stored keys work before the first request + * and additions or removals in DSH Credentials take effect immediately. * - * @param plane - the gateway plane. - * @returns whether any credential is known for it. + * Credentials and Loader remain optional, independently scoped services: a host + * without Loader can still resolve the default reference, and a host without + * Credentials can still use environment or captured keys. */ -const hasPlaneCredential = (plane: string): boolean => { - const declared = - plane === "opencode-go" - ? process.env.OPENCODE_GO_API_KEY - : process.env.OPENCODE_API_KEY; - if ( - typeof declared === "string" && - declared.length > 0 && - !isPlaceholderApiKey(declared) - ) { - return true; +const createPlaneCredentialCheck = (ctx: CordisContext) => { + const scopes: Record = { credentials: ctx, loader: ctx }; + for (const dependency of ["credentials", "loader"]) { + ctx.inject?.([dependency], (scope) => { + if (!isRecord(scope) || typeof scope.effect !== "function") { + return; + } + Reflect.apply(scope.effect, scope, [ + () => { + scopes[dependency] = scope; + return () => { + if (scopes[dependency] === scope) { + scopes[dependency] = ctx; + } + }; + }, + ]); + }); } - return getCapturedApiKey(plane) !== undefined; + + return async (plane: string): Promise => { + const route = plane === "opencode-go" ? "opencode-go" : "opencode"; + const declared = + route === "opencode-go" + ? process.env.OPENCODE_GO_API_KEY + : process.env.OPENCODE_API_KEY; + if ( + typeof declared === "string" && + declared.trim().length > 0 && + !isPlaceholderApiKey(declared) + ) { + return true; + } + if (getCapturedApiKey(plane) !== undefined) { + return true; + } + + const ref = inheritedCredentialRef(scopes.loader, route); + const resolve = readCredentialsResolver(scopes.credentials); + const hit = await resolve?.(ref); + const stored = hit?.value; + return [stored, process.env[ref]].some( + (value) => + typeof value === "string" && + value.trim().length > 0 && + !isPlaceholderApiKey(value) + ); + }; }; export const apply = ( diff --git a/test/lifecycle-credentials.test.ts b/test/lifecycle-credentials.test.ts new file mode 100644 index 00000000..a3b4631f --- /dev/null +++ b/test/lifecycle-credentials.test.ts @@ -0,0 +1,290 @@ +import { Context } from "@deepseek-ai/cordis"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CordisContext } from "../src/cordis-context.ts"; +import { inject } from "../src/identity.ts"; +import { + clearCapturedApiKeys, + recordCapturedApiKey, +} from "../src/key-capture.ts"; +import { apply } from "../src/lifecycle.ts"; + +const credential = ["test", "credential"].join("-"); +const planes = ["opencode-go", "opencode"] as const; +const defaultRefs = { + "opencode-go": "OPENCODE_GO_API_KEY", + opencode: "OPENCODE_API_KEY", +}; +const customRefs = { + "opencode-go": "ISSUE_103_GO_CREDENTIAL", + opencode: "ISSUE_103_ZEN_CREDENTIAL", +}; +const models = [{ id: "deepseek-v4.1-flash" }, { id: "beam-free" }]; +const roots: Context[] = []; + +const settle = async (root: Context) => { + for (const runtime of root.registry.values()) { + for (const fiber of runtime.fibers) await fiber.await(); + } +}; + +const credentialsService = (values = new Map()) => ({ + values, + async resolve(ref: string) { + const value = this.values.get(ref); + return value === undefined ? undefined : { value }; + }, +}); + +const loaderService = (refs: Record) => ({ + refs, + *entries() { + yield { + options: { + config: { + providers: Object.fromEntries( + Object.entries(this.refs).map(([plane, apiKeyEnv]) => [ + plane, + { apiKeyEnv }, + ]) + ), + }, + }, + }; + }, +}); + +const mount = async (services: Record = {}) => { + const root = new Context(); + roots.push(root); + const llm = { + models, + async listModels(_provider: string) { + return this.models; + }, + }; + root.provide("llm", llm as never); + for (const [name, service] of Object.entries(services)) { + root.provide(name, service as never); + } + const originalListModels = Reflect.get(llm, "listModels"); + const originalFetch = globalThis.fetch; + const plugin = root.plugin({ + inject, + apply: (scope: unknown) => { + apply(scope as CordisContext, { + enrichModels: false, + usageEnabled: false, + }); + }, + }); + await plugin.await(); + await settle(root); + expect(Reflect.get(llm, "listModels")).not.toBe(originalListModels); + return { root, llm, plugin, originalListModels, originalFetch }; +}; + +const unsetEnv = (ref: string) => { + // oxlint-disable-next-line unicorn/no-useless-undefined -- Vitest requires undefined to delete the variable. + vi.stubEnv(ref, undefined); +}; + +beforeEach(() => { + clearCapturedApiKeys(); + for (const ref of [ + ...Object.values(defaultRefs), + ...Object.values(customRefs), + ]) { + unsetEnv(ref); + } + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ data: [] })) + ); + vi.spyOn(console, "info").mockImplementation(() => {}); +}); + +afterEach(async () => { + try { + for (const root of roots.splice(0).toReversed()) await root.fiber.dispose(); + } finally { + clearCapturedApiKeys(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } +}); + +describe("apply credential-aware model listing (Issue #103)", () => { + it.each(planes)( + "keeps paid models with a stored default %s reference", + async (plane) => { + const credentials = credentialsService( + new Map([[defaultRefs[plane], credential]]) + ); + const resolve = vi.spyOn(credentials, "resolve"); + const { llm } = await mount({ credentials }); + expect(await llm.listModels(plane)).toEqual(models); + expect(resolve).toHaveBeenCalledWith(defaultRefs[plane]); + } + ); + + it.each(planes)( + "inherits only the exact %s plane's custom stored reference", + async (plane) => { + const credentials = credentialsService( + new Map([[customRefs[plane], credential]]) + ); + const resolve = vi.spyOn(credentials, "resolve"); + const loader = loaderService(customRefs); + const entries = vi.spyOn(loader, "entries"); + const { llm } = await mount({ credentials, loader }); + expect(await llm.listModels(plane)).toEqual(models); + expect(resolve).toHaveBeenCalledWith(customRefs[plane]); + expect(entries).toHaveBeenCalled(); + const other = plane === "opencode-go" ? "opencode" : "opencode-go"; + expect(await llm.listModels(other)).toEqual([models[1]]); + } + ); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["whitespace", " "], + ["placeholder", "unused"], + ["anonymous sentinel", "public"], + ] as const)( + "offers free models only for a %s stored credential", + async (_label, value) => { + const values = new Map(); + if (value !== undefined) { + for (const ref of Object.values(defaultRefs)) values.set(ref, value); + } + const { llm } = await mount({ credentials: credentialsService(values) }); + for (const plane of planes) { + expect(await llm.listModels(plane)).toEqual([models[1]]); + } + } + ); + + it("offers free models only when Credentials is absent or throws", async () => { + for (const services of [ + {}, + { + credentials: { + resolve: async () => { + throw new Error("unavailable"); + }, + }, + }, + ]) { + const { llm } = await mount(services); + for (const plane of planes) { + expect(await llm.listModels(plane)).toEqual([models[1]]); + } + } + }); + + it.each(planes)( + "does not let a stored %s key unlock the other plane", + async (plane) => { + const { llm } = await mount({ + credentials: credentialsService( + new Map([[defaultRefs[plane], credential]]) + ), + }); + const other = planes.find((candidate) => candidate !== plane)!; + expect(await llm.listModels(other)).toEqual([models[1]]); + expect(await llm.listModels(plane)).toEqual(models); + } + ); + + it.each(planes)( + "reads added and removed stored %s keys without restarting", + async (plane) => { + const credentials = credentialsService(); + const { llm } = await mount({ credentials }); + const listing = Reflect.get(llm, "listModels"); + expect(await llm.listModels(plane)).toEqual([models[1]]); + credentials.values.set(defaultRefs[plane], credential); + expect(await llm.listModels(plane)).toEqual(models); + credentials.values.delete(defaultRefs[plane]); + expect(await llm.listModels(plane)).toEqual([models[1]]); + expect(Reflect.get(llm, "listModels")).toBe(listing); + } + ); + + it.each(planes)( + "falls back to the custom %s environment reference after stored lookup", + async (plane) => { + vi.stubEnv(customRefs[plane], credential); + const credentials = credentialsService(); + const resolve = vi.spyOn(credentials, "resolve"); + const { llm } = await mount({ + credentials, + loader: loaderService(customRefs), + }); + expect(await llm.listModels(plane)).toEqual(models); + expect(resolve).toHaveBeenCalledWith(customRefs[plane]); + } + ); + + it.each(planes)( + "uses custom %s environment references without Credentials", + async (plane) => { + vi.stubEnv(customRefs[plane], credential); + const { llm } = await mount({ loader: loaderService(customRefs) }); + expect(await llm.listModels(plane)).toEqual(models); + } + ); + + it.each(planes)( + "preserves built-in environment and captured %s fast paths", + async (plane) => { + const credentials = credentialsService(); + const resolve = vi.spyOn(credentials, "resolve"); + vi.stubEnv(defaultRefs[plane], credential); + const { llm } = await mount({ credentials }); + expect(await llm.listModels(plane)).toEqual(models); + expect(resolve).not.toHaveBeenCalled(); + unsetEnv(defaultRefs[plane]); + recordCapturedApiKey(credential, plane); + expect(await llm.listModels(plane)).toEqual(models); + expect(resolve).not.toHaveBeenCalled(); + } + ); + + it("activates and clears optional Credentials and loader child scopes independently", async () => { + const { root, llm, plugin, originalListModels, originalFetch } = + await mount(); + const listing = Reflect.get(llm, "listModels"); + expect(await llm.listModels("opencode-go")).toEqual([models[1]]); + const credentials = credentialsService( + new Map([[customRefs["opencode-go"], credential]]) + ); + const credentialProvider = root.plugin((scope: Context) => { + scope.provide("credentials", credentials as never); + }); + await credentialProvider.await(); + await settle(root); + expect(await llm.listModels("opencode-go")).toEqual([models[1]]); + const loaderProvider = root.plugin((scope: Context) => { + scope.provide("loader", loaderService(customRefs) as never); + }); + await loaderProvider.await(); + await settle(root); + expect(await llm.listModels("opencode-go")).toEqual(models); + await loaderProvider.dispose(); + await settle(root); + expect(await llm.listModels("opencode-go")).toEqual([models[1]]); + credentials.values.set(defaultRefs["opencode-go"], credential); + expect(await llm.listModels("opencode-go")).toEqual(models); + await credentialProvider.dispose(); + await settle(root); + expect(await llm.listModels("opencode-go")).toEqual([models[1]]); + expect(Reflect.get(llm, "listModels")).toBe(listing); + await plugin.dispose(); + expect(Reflect.get(llm, "listModels")).toBe(originalListModels); + expect(globalThis.fetch).toBe(originalFetch); + }); +});