Skip to content
Closed
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
23 changes: 23 additions & 0 deletions apps/cli/src/commands/daemon/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
configureClientLoggerForService,
createLogger,
discoverClaudeCodeSkills,
discoverProviderModels,
flushClientSentry,
initClientSentry,
} from "@first-tree/client";
Expand Down Expand Up @@ -366,6 +367,28 @@ export function registerDaemonStartCommand(daemon: Command): void {
}).finally(() => capabilityRefresher.endInteractive(command.provider));
});

// Host-local model catalog: web opens Model settings → server asks this
// daemon → we discover from the real provider and reply on the WS.
runtime.onProviderModelsList((command) => {
void (async () => {
try {
const catalog = await discoverProviderModels(command.provider);
runtime.sendProviderModelsResult(command.ref, catalog);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
writeStatus("⚠️", `provider-models:list failed for ${command.provider}: ${message}`);
runtime.sendProviderModelsResult(command.ref, {
provider: command.provider,
models: [],
defaultModelId: null,
fetchedAt: new Date().toISOString(),
source: "unavailable",
error: message,
});
}
})();
});

await runtime.start();

// Post-register capabilities upload + arm the background poll — the
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/core/client-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getChildProcessRegistry,
getHandlerFactory,
hasHandler,
type ProviderModelsListCommand,
type RuntimeAuthCommand,
registerBuiltinHandlers,
type UpdateHooks,
Expand All @@ -17,6 +18,7 @@ import {
AGENT_BIND_REJECT_REASONS,
type AgentPinnedMessage,
type ClientPausedReason,
type ProviderModelCatalog,
type RuntimeProvider,
runtimeProviderSchema,
} from "@first-tree/shared";
Expand Down Expand Up @@ -284,6 +286,20 @@ export class ClientRuntime {
this.connection.on("runtime-auth:start", callback);
}

/**
* Register a handler for the server→client `provider-models:list` command.
* The daemon discovers models from the host-local provider and replies with
* `provider-models:result` on the same connection.
*/
onProviderModelsList(callback: (command: ProviderModelsListCommand) => void): void {
this.connection.on("provider-models:list", callback);
}

/** Reply to a correlated `provider-models:list` with the discovered catalog. */
sendProviderModelsResult(ref: string, catalog: ProviderModelCatalog): void {
this.connection.sendProviderModelsResult(ref, catalog);
}

addAgent(name: string, config: AgentConfig): void {
if (this.agentNames.has(name)) return;
// The runtime provider is a valid enum value, but this client build may not
Expand Down
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"mdast-util-from-markdown": "^2.0.3",
"pino": "^9.5.0",
"semver": "^7.6.3",
"smol-toml": "^1.7.0",
"ws": "^8.20.0",
"yaml": "^2.8.3",
"zod": "^4.0.0"
Expand Down
87 changes: 87 additions & 0 deletions packages/client/src/__tests__/discover-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
parseCursorModelsOutput,
parseKimiConfigModels,
resolveKimiConfigPath,
} from "../runtime/capabilities/discover-models.js";

describe("parseCursorModelsOutput", () => {
it("parses id/label rows and marks the default", () => {
const parsed = parseCursorModelsOutput(`Available models

auto - Auto (default)
gpt-5.2 - GPT-5.2
composer-2.5 - Composer 2.5
`);
expect(parsed.defaultModelId).toBe("auto");
expect(parsed.models).toEqual([
{ id: "auto", label: "Auto", isDefault: true, hint: "default" },
{ id: "gpt-5.2", label: "GPT-5.2" },
{ id: "composer-2.5", label: "Composer 2.5" },
]);
});
});

describe("resolveKimiConfigPath", () => {
it("defaults to ~/.kimi-code/config.toml", () => {
expect(resolveKimiConfigPath({}, "/home/user")).toBe("/home/user/.kimi-code/config.toml");
});

it("honors KIMI_CODE_HOME relocation", () => {
expect(resolveKimiConfigPath({ KIMI_CODE_HOME: "/opt/kimi" }, "/home/user")).toBe("/opt/kimi/config.toml");
});

it("trims KIMI_CODE_HOME and ignores empty", () => {
expect(resolveKimiConfigPath({ KIMI_CODE_HOME: " /custom/kimi " }, "/home/user")).toBe(
"/custom/kimi/config.toml",
);
expect(resolveKimiConfigPath({ KIMI_CODE_HOME: " " }, "/home/user")).toBe("/home/user/.kimi-code/config.toml");
});
});

describe("parseKimiConfigModels", () => {
it('reads default_model and quoted [models."."] sections', () => {
const parsed = parseKimiConfigModels(`
default_model = "kimi-code/k3"

[models."kimi-code/kimi-for-coding"]
provider = "managed:kimi-code"
model = "kimi-for-coding"
display_name = "K2.7 Coding"

[models."kimi-code/k3"]
provider = "managed:kimi-code"
model = "k3"
display_name = "K3"
`);
expect(parsed.defaultModelId).toBe("kimi-code/k3");
expect(parsed.models).toEqual([
{ id: "kimi-code/kimi-for-coding", label: "K2.7 Coding" },
{ id: "kimi-code/k3", label: "K3", isDefault: true, hint: "default" },
]);
});

it("accepts bare model aliases and marks default_model", () => {
const parsed = parseKimiConfigModels(`
default_model = "gemini-3-pro-preview"

[models.gemini-3-pro-preview]
provider = "openai"
model = "gemini-3-pro-preview"
display_name = "Gemini 3 Pro"

[models."kimi-code/k3"]
provider = "managed:kimi-code"
model = "k3"
display_name = "K3"
`);
expect(parsed.defaultModelId).toBe("gemini-3-pro-preview");
expect(parsed.models).toEqual(
expect.arrayContaining([
{ id: "gemini-3-pro-preview", label: "Gemini 3 Pro", isDefault: true, hint: "default" },
{ id: "kimi-code/k3", label: "K3" },
]),
);
expect(parsed.models).toHaveLength(2);
});
});
26 changes: 26 additions & 0 deletions packages/client/src/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
inboxDeliverFrameSchema,
inboxRecoverAcceptedFrameSchema,
inboxRecoverRejectedFrameSchema,
PROVIDER_MODELS_LIST_TYPE,
PROVIDER_MODELS_RESULT_TYPE,
type ProviderModelCatalog,
providerModelsListCommandSchema,
RUNTIME_AUTH_START_TYPE,
type RuntimeAuthMethod,
type RuntimeProvider,
Expand Down Expand Up @@ -164,6 +168,12 @@ export type RuntimeAuthCommand = {
ref: string;
};

/** Server→client command to discover host-local provider models. */
export type ProviderModelsListCommand = {
provider: RuntimeProvider;
ref: string;
};

/**
* Welcome frame received after `auth:ok`. `isReconnect` is true for every
* occurrence after the first welcome in the lifetime of this `ClientConnection`
Expand Down Expand Up @@ -199,6 +209,7 @@ type ClientConnectionEvents = {
"agent:pinned": [message: AgentPinnedMessage];
"session:command": [command: SessionCommand];
"runtime-auth:start": [command: RuntimeAuthCommand];
"provider-models:list": [command: ProviderModelsListCommand];
"session:reconcile:result": [result: SessionReconcileResult];
"auth:expired": [];
/**
Expand Down Expand Up @@ -1082,6 +1093,12 @@ export class ClientConnection extends EventEmitter<ClientConnectionEvents> {
this.ws.send(JSON.stringify({ type: "session:reconcile", agentId, chatIds }));
}

/** Reply to a `provider-models:list` reverse command with the host catalog. */
sendProviderModelsResult(ref: string, catalog: ProviderModelCatalog): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify({ type: PROVIDER_MODELS_RESULT_TYPE, ref, catalog }));
}

async disconnect(): Promise<void> {
this.closing = true;
this.connectAbort?.abort();
Expand Down Expand Up @@ -1579,6 +1596,15 @@ export class ClientConnection extends EventEmitter<ClientConnectionEvents> {
return;
}

if (type === PROVIDER_MODELS_LIST_TYPE) {
const parsed = providerModelsListCommandSchema.safeParse(msg);
if (parsed.success) {
const { provider, ref } = parsed.data;
this.emit("provider-models:list", { provider, ref });
}
return;
}

if (type === "session:reconcile:result") {
const agentId = msg.agentId as string;
const staleChatIds = Array.isArray(msg.staleChatIds) ? (msg.staleChatIds as string[]) : null;
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type {
BoundAgent,
ClientConnectionConfig,
ProviderModelsListCommand,
RuntimeAuthCommand,
ServerWelcome,
SessionCommand,
Expand Down Expand Up @@ -42,6 +43,12 @@ export {
resolveCodexRuntimeBinary,
} from "./runtime/capabilities/codex.js";
export { probeCursorCapability } from "./runtime/capabilities/cursor.js";
export {
type DiscoverModelsDeps,
discoverProviderModels,
parseCursorModelsOutput,
parseKimiConfigModels,
} from "./runtime/capabilities/discover-models.js";
export {
CAPABILITY_REFRESH_BASE_MS,
CAPABILITY_REFRESH_MAX_MS,
Expand All @@ -54,6 +61,7 @@ export {
revalidateCapabilities,
shouldFullReprobe,
} from "./runtime/capabilities/index.js";

export type {
AdoptOptions,
ChildCategory,
Expand Down
Loading
Loading