feat: per-model max output tokens and a conversation-time thinking level backed by agent settings (#28)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,6 +206,13 @@ export interface ModelInfo {
|
||||
* unset (= treated as supported).
|
||||
*/
|
||||
vision?: boolean;
|
||||
/**
|
||||
* Per-model max output tokens (TOML `max_tokens` annotation; user-only, never preset by the
|
||||
* built-in catalog): when set it wins over the Agent's `system_config.model.max_tokens`;
|
||||
* unset = inherit the Agent value. Lets a small-context model cap its output below the
|
||||
* seeded per-Agent default (32000), which cannot fit into e.g. a 32k context window.
|
||||
*/
|
||||
maxTokens?: number;
|
||||
pricing?: ModelPricingDto;
|
||||
/** Environment variable name to fall back to when api_key is empty (e.g. ANTHROPIC_API_KEY); unset if no known fallback. */
|
||||
envKey?: string;
|
||||
@@ -241,6 +248,8 @@ export interface ModelUpdateEntry {
|
||||
clientType?: string;
|
||||
/** Whether image input (vision/multimodal) is supported; omitted = supported (not persisted). */
|
||||
vision?: boolean;
|
||||
/** Per-model max output tokens, a positive integer (wins over the Agent config); omitted = inherit the Agent value (the annotation is cleared). */
|
||||
maxTokens?: number;
|
||||
pricing?: ModelPricingDto;
|
||||
/** Providing it overwrites and updates createdAt; omitting it keeps the existing value. */
|
||||
apiKey?: string;
|
||||
|
||||
@@ -76,6 +76,12 @@ function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
|
||||
}
|
||||
entry.vision = m.vision;
|
||||
}
|
||||
if (m.maxTokens !== undefined) {
|
||||
if (typeof m.maxTokens !== "number" || !Number.isInteger(m.maxTokens) || m.maxTokens <= 0) {
|
||||
throw badRequest(`models[${i}].maxTokens must be a positive integer.`);
|
||||
}
|
||||
entry.maxTokens = m.maxTokens;
|
||||
}
|
||||
if (m.pricing !== undefined) {
|
||||
const p = m.pricing as Record<string, unknown>;
|
||||
if (p === null || typeof p !== "object" || Array.isArray(p)) {
|
||||
|
||||
@@ -350,6 +350,8 @@ export class ProjectConfigService {
|
||||
// routed has no fallback (no envKey, and AgentHub will reject that id).
|
||||
const envKey = resolveModelEnv(modelId, clientType)?.envKey;
|
||||
const vision = typeof m.vision === "boolean" ? m.vision : cat?.supportsVision;
|
||||
// Output cap: TOML annotation only (user-owned; the built-in catalog never presets it).
|
||||
const maxTokens = optNum(m.max_tokens);
|
||||
// Display name: the explicit TOML field (user-edited) takes priority, then the built-in catalog.
|
||||
const displayName = optStr(m.display_name) ?? cat?.displayName;
|
||||
// credential is inlined on the entry: a credential block is emitted if either api_key or base_url is present.
|
||||
@@ -369,6 +371,7 @@ export class ProjectConfigService {
|
||||
: {}),
|
||||
...(clientType ? { clientType } : {}),
|
||||
...(vision !== undefined ? { vision } : {}),
|
||||
...(maxTokens !== undefined ? { maxTokens } : {}),
|
||||
...(envKey ? { envKey } : {}),
|
||||
...(pricingDto ? { pricing: pricingDto } : {}),
|
||||
...(apiKey !== undefined || credBaseUrl !== undefined
|
||||
@@ -443,6 +446,7 @@ export class ProjectConfigService {
|
||||
delete next.context_window;
|
||||
delete next.client_type;
|
||||
delete next.vision;
|
||||
delete next.max_tokens;
|
||||
delete next.pricing;
|
||||
delete next.display_name;
|
||||
// Leftover key from the old concatenated format (request_model_id): defensively stripped, never written to disk again.
|
||||
@@ -460,6 +464,8 @@ export class ProjectConfigService {
|
||||
if (entry.clientType) next.client_type = entry.clientType;
|
||||
// Treated as supported by default: only written to disk when explicitly annotated (both true/false are kept; false drives a frontend blocking hint).
|
||||
if (entry.vision !== undefined) next.vision = entry.vision;
|
||||
// Inherit-the-Agent-value by default: only written to disk when explicitly annotated (omitted on a full-table PUT = the annotation is cleared).
|
||||
if (entry.maxTokens !== undefined) next.max_tokens = entry.maxTokens;
|
||||
if (entry.pricing !== undefined) {
|
||||
next.pricing = {
|
||||
unit: "usd_per_mtok",
|
||||
|
||||
@@ -162,6 +162,43 @@ describe("models preset & catalog enrichment", () => {
|
||||
expect(noProvider.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PUT maxTokens:落盘为 max_tokens 并经 GET 回读;整表省略即清除;0/负数/非数字 400", async () => {
|
||||
const put = await api.put(url(), {
|
||||
models: [
|
||||
{ provider: "custom", modelId: "local-qwen", clientType: "openai", maxTokens: 8000 },
|
||||
],
|
||||
});
|
||||
expect(put.status).toBe(200);
|
||||
expect(pick((await put.json()) as ModelsResponse, "custom", "local-qwen").maxTokens).toBe(8000);
|
||||
|
||||
// Round-trips through disk (persisted as snake_case on the entry, not just echoed back).
|
||||
const again = (await (await api.get(url())).json()) as ModelsResponse;
|
||||
expect(pick(again, "custom", "local-qwen").maxTokens).toBe(8000);
|
||||
const toml = await readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8");
|
||||
expect(toml).toContain("max_tokens = 8000");
|
||||
|
||||
// Full-table PUT omitting the field clears the annotation (same replace semantics as vision/contextWindow).
|
||||
const cleared = await api.put(url(), {
|
||||
models: [{ provider: "custom", modelId: "local-qwen", clientType: "openai" }],
|
||||
});
|
||||
expect(cleared.status).toBe(200);
|
||||
const clearedRow = pick((await cleared.json()) as ModelsResponse, "custom", "local-qwen");
|
||||
expect("maxTokens" in clearedRow).toBe(false);
|
||||
expect(
|
||||
await readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8"),
|
||||
).not.toContain("max_tokens");
|
||||
|
||||
// Not a positive integer → 400 with the field-labelled message (nothing written).
|
||||
for (const bad of [0, -5, 1.5, "8000"]) {
|
||||
const res = await api.put(url(), {
|
||||
models: [{ provider: "custom", modelId: "local-qwen", maxTokens: bad }],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { message: string } };
|
||||
expect(body.error.message).toContain("models[0].maxTokens");
|
||||
}
|
||||
});
|
||||
|
||||
it("同名 model_id 可在不同 provider 下并存(成对键,互不覆盖)", async () => {
|
||||
const put = await api.put(url(), {
|
||||
models: [
|
||||
|
||||
Reference in New Issue
Block a user