summaryrefslogtreecommitdiffhomepage
path: root/packages/credential-store
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
commit7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch)
tree247d03408ecccd633290ea56b1b08811ebe460ec /packages/credential-store
parent4283d1f8a0bc3953e65962a2364c903d0015f047 (diff)
downloaddispatch-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.json11
-rw-r--r--packages/credential-store/src/extension.ts29
-rw-r--r--packages/credential-store/src/index.ts9
-rw-r--r--packages/credential-store/src/registry.test.ts81
-rw-r--r--packages/credential-store/src/registry.ts80
-rw-r--r--packages/credential-store/src/service.ts4
-rw-r--r--packages/credential-store/tsconfig.json6
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" }]
+}