diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
| commit | 7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch) | |
| tree | 247d03408ecccd633290ea56b1b08811ebe460ec /packages/credential-store | |
| parent | 4283d1f8a0bc3953e65962a2364c903d0015f047 (diff) | |
| download | dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.tar.gz dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.zip | |
feat(backend): credential-store + model selection/catalog (GET /models) + per-turn cwd through orchestrator/transport/host-bin
Diffstat (limited to 'packages/credential-store')
| -rw-r--r-- | packages/credential-store/package.json | 11 | ||||
| -rw-r--r-- | packages/credential-store/src/extension.ts | 29 | ||||
| -rw-r--r-- | packages/credential-store/src/index.ts | 9 | ||||
| -rw-r--r-- | packages/credential-store/src/registry.test.ts | 81 | ||||
| -rw-r--r-- | packages/credential-store/src/registry.ts | 80 | ||||
| -rw-r--r-- | packages/credential-store/src/service.ts | 4 | ||||
| -rw-r--r-- | packages/credential-store/tsconfig.json | 6 |
7 files changed, 220 insertions, 0 deletions
diff --git a/packages/credential-store/package.json b/packages/credential-store/package.json new file mode 100644 index 0000000..19f6b07 --- /dev/null +++ b/packages/credential-store/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dispatch/credential-store", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } +} diff --git a/packages/credential-store/src/extension.ts b/packages/credential-store/src/extension.ts new file mode 100644 index 0000000..8f2a826 --- /dev/null +++ b/packages/credential-store/src/extension.ts @@ -0,0 +1,29 @@ +import type { Extension, Manifest } from "@dispatch/kernel"; +import type { Credential } from "./registry.js"; +import { createCredentialStore } from "./registry.js"; +import { credentialStoreHandle } from "./service.js"; + +export const manifest: Manifest = { + id: "credential-store", + name: "Credential Store", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["credential-store/registry"] }, +}; + +export function createCredentialStoreExtension(deps: { + credentials: readonly Credential[]; +}): Extension { + return { + manifest, + activate(host) { + const store = createCredentialStore({ + credentials: deps.credentials, + getProvider: (id) => host.getProviders().get(id), + }); + host.provideService(credentialStoreHandle, store); + }, + }; +} diff --git a/packages/credential-store/src/index.ts b/packages/credential-store/src/index.ts new file mode 100644 index 0000000..749abed --- /dev/null +++ b/packages/credential-store/src/index.ts @@ -0,0 +1,9 @@ +export { createCredentialStoreExtension, manifest } from "./extension.js"; +export type { + Credential, + CredentialStore, + CredentialStoreDeps, + ResolvedModel, +} from "./registry.js"; +export { createCredentialStore } from "./registry.js"; +export { credentialStoreHandle } from "./service.js"; diff --git a/packages/credential-store/src/registry.test.ts b/packages/credential-store/src/registry.test.ts new file mode 100644 index 0000000..e67b5d4 --- /dev/null +++ b/packages/credential-store/src/registry.test.ts @@ -0,0 +1,81 @@ +import type { ProviderContract } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { createCredentialStore } from "./registry.js"; + +function fakeProvider( + id: string, + listModels?: () => Promise<readonly { id: string }[]>, +): ProviderContract { + return { + id, + stream: async function* () {}, + ...(listModels ? { listModels } : {}), + }; +} + +describe("createCredentialStore", () => { + const credentials = [{ name: "opencode", providerId: "openai-compat" }]; + const providers = new Map<string, ProviderContract>(); + + const store = createCredentialStore({ + credentials, + getProvider: (id) => providers.get(id), + }); + + describe("resolve", () => { + it("resolves a simple model name", () => { + providers.set("openai-compat", fakeProvider("openai-compat")); + const result = store.resolve("opencode/deepseek-v4-flash"); + expect(result).toEqual({ providerId: "openai-compat", model: "deepseek-v4-flash" }); + }); + + it("resolves a model name with slashes", () => { + const result = store.resolve("opencode/a/b"); + expect(result).toEqual({ providerId: "openai-compat", model: "a/b" }); + }); + + it("returns undefined for unknown credential name", () => { + const result = store.resolve("unknown/x"); + expect(result).toBeUndefined(); + }); + + it("returns undefined for no slash", () => { + const result = store.resolve("noslash"); + expect(result).toBeUndefined(); + }); + + it("returns undefined for empty model segment", () => { + const result = store.resolve("opencode/"); + expect(result).toBeUndefined(); + }); + }); + + describe("listCatalog", () => { + it("lists models from providers with listModels", async () => { + providers.set( + "openai-compat", + fakeProvider("openai-compat", async () => [{ id: "m1" }, { id: "m2" }]), + ); + + const catalog = await store.listCatalog(); + expect(catalog).toEqual(["opencode/m1", "opencode/m2"]); + }); + + it("skips credentials whose provider has no listModels", async () => { + providers.set("openai-compat", fakeProvider("openai-compat")); + + const catalog = await store.listCatalog(); + expect(catalog).toEqual([]); + }); + + it("skips credentials whose provider is missing", async () => { + const emptyStore = createCredentialStore({ + credentials: [{ name: "missing", providerId: "nonexistent" }], + getProvider: () => undefined, + }); + + const catalog = await emptyStore.listCatalog(); + expect(catalog).toEqual([]); + }); + }); +}); diff --git a/packages/credential-store/src/registry.ts b/packages/credential-store/src/registry.ts new file mode 100644 index 0000000..c1ba771 --- /dev/null +++ b/packages/credential-store/src/registry.ts @@ -0,0 +1,80 @@ +import type { ProviderContract } from "@dispatch/kernel"; + +export interface Credential { + readonly name: string; + readonly providerId: string; +} + +export interface ResolvedModel { + readonly providerId: string; + readonly model: string; +} + +export interface CredentialStore { + /** + * Split a model name on the FIRST "/": name=before, model=after (model may contain "/"). + * Look up the credential by name; return its providerId + the model id, or undefined if + * the name is unknown or there is no model segment. + */ + resolve(modelName: string): ResolvedModel | undefined; + + /** + * The model catalog: for each credential, look up its provider and call listModels(), + * emitting `${credential.name}/${modelInfo.id}`. Skip credentials whose provider is + * missing or has no listModels. + */ + listCatalog(): Promise<readonly string[]>; +} + +export interface CredentialStoreDeps { + readonly credentials: readonly Credential[]; + readonly getProvider: (id: string) => ProviderContract | undefined; +} + +export function createCredentialStore(deps: CredentialStoreDeps): CredentialStore { + const credentialMap = new Map<string, string>(); + for (const credential of deps.credentials) { + credentialMap.set(credential.name, credential.providerId); + } + + return { + resolve(modelName: string): ResolvedModel | undefined { + const slashIndex = modelName.indexOf("/"); + if (slashIndex === -1) { + return undefined; + } + + const credentialName = modelName.slice(0, slashIndex); + const model = modelName.slice(slashIndex + 1); + + if (!model) { + return undefined; + } + + const providerId = credentialMap.get(credentialName); + if (!providerId) { + return undefined; + } + + return { providerId, model }; + }, + + async listCatalog(): Promise<readonly string[]> { + const results: string[] = []; + + for (const credential of deps.credentials) { + const provider = deps.getProvider(credential.providerId); + if (!provider?.listModels) { + continue; + } + + const models = await provider.listModels(); + for (const model of models) { + results.push(`${credential.name}/${model.id}`); + } + } + + return results; + }, + }; +} diff --git a/packages/credential-store/src/service.ts b/packages/credential-store/src/service.ts new file mode 100644 index 0000000..9f06064 --- /dev/null +++ b/packages/credential-store/src/service.ts @@ -0,0 +1,4 @@ +import { defineService } from "@dispatch/kernel"; +import type { CredentialStore } from "./registry.js"; + +export const credentialStoreHandle = defineService<CredentialStore>("credential-store/registry"); diff --git a/packages/credential-store/tsconfig.json b/packages/credential-store/tsconfig.json new file mode 100644 index 0000000..ff99a43 --- /dev/null +++ b/packages/credential-store/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] +} |
