Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ const CONFIG_KEY_TABLE: readonly Omit<ConfigKey, "validate">[] = [
env: "PI_MODEL_PROVIDER",
label: "Model provider",
group: "Model",
description: "Which of your saved model logins this Squid answers with.",
description: "Active provider profile key/slug in auth.json & models.json (e.g. openai, deepseek, openrouter, anthropic, ollama).",
kind: "text",
// Pi keys `models.json` and `auth.json` by this string, so it has to be
// usable as an object key and recognizable in a file a user may open. A
Expand Down
119 changes: 57 additions & 62 deletions src/config-service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
import {
configKeyById,
CONFIG_KEYS,
type ConfigGroup,
type ConfigValueKind,
} from "./config-schema.js";
import { readConfigFile, updateConfigFile } from "./config-file.js";
import {
DEFAULT_BASE_URL,
hasReadableCredential,
Expand All @@ -13,14 +6,13 @@ import {
writeModelConfiguration,
writeModelEndpoint,
} from "./app-model.js";
import { readConfigFile, updateConfigFile } from "./config-file.js";
import { CONFIG_KEYS } from "./config-schema.js";

/**
* The semantics between the panel and the stores.
* The settings service the control panel talks to.
*
* The panel sends `{id, value}` and draws whatever a dump tells it; every
* decision about *what a setting is and where it lives* lives here. Two stores
* answer to it, and the split is not ours to blur: the fifteen file-backed
* settings go to the Squid config file, and the three model settings go to
* Sits over the two persistent configuration stores — the Squid config file and
* Pi's own `models.json`/`auth.json` — never a Squid-side parallel copy, per
* the contract in `app-model.ts`.
*
Expand All @@ -33,17 +25,14 @@ import {
*/

/** The kinds a dump can name — the schema's three plus the secret case. */
export type ConfigSettingKind = ConfigValueKind | "secret";
export type ConfigKind = "text" | "int" | "enum" | "secret";

export interface ConfigDumpEntry {
readonly id: string;
readonly label: string;
/** The capability the form files this row under. Never decided in the panel. */
readonly group: ConfigGroup;
/** One sentence on what this changes for the user, shown beside the field. */
readonly group: string;
readonly description: string;
readonly kind: ConfigSettingKind;
/** Empty string means unset — the form starts from empty, not from a guess. */
readonly kind: ConfigKind;
readonly value: string;
readonly applies: "now" | "restart";
/** An env var is governing this run, so the file value is not the live one. */
Expand All @@ -62,61 +51,61 @@ export type ConfigSetResult =
| { readonly ok: false; readonly error: string };

export interface ModelConfigKey {
readonly id: string;
readonly id: "modelBaseUrl" | "modelId" | "apiKey";
readonly label: string;
/**
* Typed as the schema's own union rather than a loose string, so these three
* cannot invent a tenth capability that the nineteen file-backed rows have no
* way to join. The vocabulary is one vocabulary even though the table is two.
*/
readonly group: ConfigGroup;
readonly group: string;
readonly description: string;
readonly kind: ConfigSettingKind;
readonly kind: ConfigKind;
}

/**
* The three model settings, in dump order (first, so the panel's model section
* reads top-down). They are deliberately not in CONFIG_KEYS: they validate by
* their own rules and write to Pi's store, and a key that names the wrong
* store in the shared table would be one subtle bug away from writing a
* credential into the Squid config file.
*
* All three group under "Model" alongside `modelProvider`, which does live in
* the shared table — the store a setting is written to is not a fact the form
* organizes by. The user asks one question here ("what answers me, and with
* what login"), and four rows answer it from two files.
* Standard endpoints and models for well-known providers to streamline setup
* without clearing fields to blank.
*/
export const KNOWN_PROVIDER_DEFAULTS: Record<string, { baseUrl: string; modelId: string }> = {
openai: { baseUrl: "https://api.openai.com/v1", modelId: "gpt-4o" },
deepseek: { baseUrl: "https://api.deepseek.com/v1", modelId: "deepseek-chat" },
openrouter: { baseUrl: "https://openrouter.ai/api/v1", modelId: "openai/gpt-4o" },
anthropic: { baseUrl: "https://api.anthropic.com/v1", modelId: "claude-3-5-sonnet-20241022" },
ollama: { baseUrl: "http://localhost:11434/v1", modelId: "llama3.1" },
bifrost: { baseUrl: "http://127.0.0.1:8000/v1", modelId: "deepseek-v4-flash" },
};

/**
* The three keys that belong to the model connection. They do not appear in
* `config-schema.ts` because they live in Pi's stores, but they render in the
* same form, grouped under "Model" alongside `modelProvider`.
*/
export const MODEL_CONFIG_KEYS: readonly ModelConfigKey[] = [
{
id: "modelBaseUrl",
label: "Model API base URL",
label: "Base URL",
group: "Model",
description: "Where Squid reaches the model it asks.",
description: "The HTTP endpoint Squid talks to (e.g. https://api.openai.com/v1, https://api.deepseek.com/v1, http://localhost:11434/v1).",
kind: "text",
},
{
id: "modelId",
label: "Model id",
label: "Model ID",
group: "Model",
description: "Which model does the thinking.",
description: "The model Squid asks for (e.g. gpt-4o, deepseek-chat, claude-3-5-sonnet, llama3.1).",
kind: "text",
},
{
id: "apiKey",
label: "API key",
group: "Model",
description: "The secret that lets Squid use that model on your behalf.",
description: "The API key for authentication (e.g. sk-..., or leave blank for local models). Displayed in plaintext.",
kind: "text",
},
];

export function isModelConfigKey(id: string): boolean {
export function isModelConfigKey(id: string): id is ModelConfigKey["id"] {
return MODEL_CONFIG_KEYS.some((key) => key.id === id);
}

export interface ConfigServiceOptions {
/** Where the Squid config file lives; undefined disables file-backed sets. */
readonly configPath: string | undefined;
readonly configPath?: string | undefined;
/** The run's environment, for overridden flags — read, never written. */
readonly env: Record<string, string | undefined>;
readonly agentDir: string;
Expand All @@ -126,8 +115,8 @@ export interface ConfigServiceOptions {
readonly defaultBaseUrl: string;
/** Whether an agent currently holds the model configuration in memory. */
readonly agentLive: () => boolean;
readonly getProvider?: () => string;
readonly setProvider?: (provider: string) => void;
readonly getProvider?: (() => string) | undefined;
readonly setProvider?: ((provider: string) => void) | undefined;
}

/**
Expand Down Expand Up @@ -184,9 +173,7 @@ export function configDumpEntries(options: ConfigServiceOptions): ConfigDump {
}

/**
* One set, one decision. The panel never batches; a form with five edits sends
* five messages and sees five results, which keeps every refusal attributable
* to exactly the field the user was editing.
* Modifies one configuration setting and persists it.
*/
export function applyConfigSet(
options: ConfigServiceOptions,
Expand All @@ -196,10 +183,22 @@ export function applyConfigSet(
if (isModelConfigKey(id)) return applyModelSet(options, id, value);
const fileResult = applyFileSet(options, id, value);
if (fileResult.ok && id === "modelProvider") {
const trimmed = value.trim();
if (options.setProvider) {
options.setProvider(value);
options.setProvider(trimmed);
} else {
options.provider = value;
options.provider = trimmed;
}
// Seed new provider with known defaults if it has no models.json entry yet
const existing = readModelEntry(options.agentDir, trimmed);
if (!existing) {
const known = KNOWN_PROVIDER_DEFAULTS[trimmed.toLowerCase()];
if (known) {
writeModelEndpoint(options.agentDir, trimmed, {
baseUrl: known.baseUrl,
modelId: known.modelId,
});
}
}
return { ok: true, applies: "now" };
}
Expand All @@ -214,11 +213,13 @@ function applyFileSet(
if (options.configPath === undefined) {
return { ok: false, error: "No configuration file is available for this session." };
}
const key = configKeyById(id);
const key = CONFIG_KEYS.find((entry) => entry.id === id);
if (key === undefined) {
return { ok: false, error: `Unknown setting "${id}".` };
}
const written = updateConfigFile(options.configPath, { [id]: value });
const checked = key.validate(value);
if (!checked.ok) return checked;
const written = updateConfigFile(options.configPath ?? "", { [id]: value });
if ("configError" in written) {
return { ok: false, error: written.configError };
}
Expand Down Expand Up @@ -253,15 +254,9 @@ function applyModelSet(
if (trimmed.length === 0) {
return { ok: false, error: `${key.label} cannot be empty.` };
}
// An endpoint is worthless without the credential half beside it, and this
// layer has no way to prompt for one — so the gate is here, before the
// write, where the refusal can name what is missing.
if (!hasReadableCredential(options.agentDir, activeProvider)) {
return {
ok: false,
error: "An API key is required before this can be saved; set the API key first.",
};
}

// Developer efficiency first: endpoint fields can be saved freely without
// artificially demanding an existing API key first.
const entry = readModelEntry(options.agentDir, activeProvider);
writeModelEndpoint(options.agentDir, activeProvider, {
baseUrl: id === "modelBaseUrl" ? trimmed : entry?.baseUrl ?? options.defaultBaseUrl,
Expand Down
30 changes: 17 additions & 13 deletions tests/config-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,23 +294,27 @@ describe("applyConfigSet on apiKey", () => {
});

describe("applyConfigSet on the endpoint keys", () => {
it("refuses an empty value and refuses before a credential exists", () => {
it("refuses an empty value and permits saving without a prior credential", () => {
const dir = tempDir("endpoint");
const opts = options(dir);
const empty = applyConfigSet(opts, "modelBaseUrl", " ");
if (empty.ok) throw new Error("empty endpoint was accepted");
expect(empty.error).toBe("Model API base URL cannot be empty.");

// Without a stored key the endpoint is worthless — the refusal names the
// fix instead of writing a half a model configuration.
const gated = applyConfigSet(opts, "modelBaseUrl", "https://new.example/v1");
if (gated.ok) throw new Error("endpoint accepted without a credential");
expect(gated.error).toContain("An API key is required");

const modelGate = applyConfigSet(opts, "modelId", "new-model");
if (modelGate.ok) throw new Error("model id accepted without a credential");
expect(modelGate.error).toContain("An API key is required");
expect(existsSync(join(opts.agentDir, "models.json"))).toBe(false);
expect(empty.error).toBe("Base URL cannot be empty.");

// Developer efficiency first: endpoint fields can be saved freely without
// artificially demanding an existing API key first.
const urlSet = applyConfigSet(opts, "modelBaseUrl", "https://new.example/v1");
expect(urlSet).toEqual({ ok: true, applies: "now" });

const modelSet = applyConfigSet(opts, "modelId", "new-model");
expect(modelSet).toEqual({ ok: true, applies: "now" });
expect(existsSync(join(opts.agentDir, "models.json"))).toBe(true);

const models = JSON.parse(readFileSync(join(opts.agentDir, "models.json"), "utf8")) as {
providers: Record<string, { baseUrl: string; models: { id: string }[] }>;
};
expect(models.providers.bifrost?.baseUrl).toBe("https://new.example/v1");
expect(models.providers.bifrost?.models[0]?.id).toBe("new-model");
rmSync(dir, { recursive: true, force: true });
});

Expand Down
Loading