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
29 changes: 16 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,19 @@ NODE_ENV=development
# AI providers — all OpenAI-compatible (via OpenAI SDK)
#
# Chains:
# SMART_CHAIN: z.ai AI_MODEL → Claude CLAUDE_MODEL → HF HF_MODEL
# FAST_CHAIN: z.ai AI_FAST_MODEL → Claude CLAUDE_FAST_MODEL → HF HF_FAST_MODEL
# SMART_CHAIN: Claude CLAUDE_MODEL → z.ai AI_MODEL → HF HF_MODEL
# FAST_CHAIN: Claude CLAUDE_FAST_MODEL → z.ai AI_FAST_MODEL → HF HF_FAST_MODEL
# OCR_CHAIN: Claude CLAUDE_VISION_MODEL → HF HF_VISION_MODEL (fixed order)
#
# The SMART/FAST order is configuration: AI_SMART_CHAIN / AI_FAST_CHAIN, comma-separated provider ids
# (zai, claude, hf, openrouter). Unset = the default order above. Unknown ids are ignored with a warning.
#AI_SMART_CHAIN=zai,claude,hf
#AI_FAST_CHAIN=zai,claude,hf
# (claude, zai, openrouter, hf). Unset = the default order above. Unknown ids are ignored with a warning.
# Among selected providers Claude is primary and HF is last; other providers keep their order.
# Health can demote Claude behind other priority providers, while HF remains the final reserve.
#AI_SMART_CHAIN=claude,zai,hf
#AI_FAST_CHAIN=claude,zai,hf
#
# OpenRouter is optional and in no default order: it runs only when named in a chain order above
# (e.g. AI_SMART_CHAIN=zai,openrouter,claude,hf) AND both its key and the chain's model are set.
# (e.g. AI_SMART_CHAIN=claude,zai,openrouter,hf) AND both its key and the chain's model are set.
#
# z.ai and HF fields are required. Claude is optional: when its key is absent, the slot
# is skipped and the rest of the chain runs unchanged (without either CLAUDE_API_TOKEN or CLAUDE_API_TOKEN2, OCR is HF-only).
Expand All @@ -55,7 +57,7 @@ AI_BASE_URL=https://api.z.ai/api/coding/paas/v4
AI_MODEL=glm-5.3
AI_FAST_MODEL=glm-5.3-flash

# Anthropic Claude via its OpenAI-compatible endpoint (optional; next after z.ai GLM, OCR primary).
# Anthropic Claude via its OpenAI-compatible endpoint (optional key; primary in smart/fast/OCR).
# Smart chain uses Sonnet, fast chain (validators, bank prefill, merchant agent) uses Haiku,
# receipt OCR uses CLAUDE_VISION_MODEL (default Sonnet: on degraded receipt photos Haiku emitted a
# wrong but sum-verified receipt, Sonnet refused instead — measured 2026-10-12).
Expand All @@ -76,11 +78,12 @@ AI_FAST_MODEL=glm-5.3-flash
# CLAUDE_VISION_MODEL=claude-sonnet-5-5

# HuggingFace Router (text fallback + vision fallback)
# Provider routes verified in the 2026-11-13 catalog: tools and structured output supported.
HF_TOKEN=hf_XXX
HF_BASE_URL=https://router.huggingface.co/v1
HF_MODEL=Qwen/Qwen3-235B-A22B
HF_FAST_MODEL=meta-llama/Llama-3.3-70B-Instruct
HF_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
HF_MODEL=Qwen/Qwen3.8-2.4T-A95B:deepinfra
HF_FAST_MODEL=meta-llama/Llama-4-Scout-17B-16E-Instruct:deepinfra
HF_VISION_MODEL=Qwen/Qwen3.8-Flash-Next-FP8:nscale

# Role routing (stage B3; off by default). AI_ROUTING=roles runs agent rounds on per-role chains:
# executor = ordinary tool rounds, repair = after the stall guard steps in. Entries are `provider`
Expand All @@ -90,8 +93,8 @@ HF_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
#AI_ROUTING=legacy
#AI_ROUTING_USER_IDS=
#AI_PLANNER_CHAIN=
#AI_EXECUTOR_CHAIN=openrouter:glm53-flash-low,openrouter:deepseek41-flash,claude
#AI_REPAIR_CHAIN=openrouter:glm53-low,claude
#AI_EXECUTOR_CHAIN=claude,openrouter:glm53-flash-low,openrouter:deepseek41-flash
#AI_REPAIR_CHAIN=claude,openrouter:glm53-low

# Hybrid tool catalog (#203 step B; off by default; same names as HyperCalendarBot). `full` sends
# every tool schema in every request; `hybrid` sends an always-on core (8 tools) plus a compact
Expand All @@ -102,7 +105,7 @@ HF_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
#AI_TOOL_SCHEMA_USER_IDS=123456789,987654321

# OpenRouter (optional, OpenAI-compatible; skipped when key or model is unset). Model ids are OpenRouter's,
# e.g. deepseek/deepseek-v4.1-flash, z-ai/glm-5.3-flash, anthropic/claude-haiku-5.5.
# e.g. deepseek/deepseek-v4.1-flash-1017, z-ai/glm-5.3-flash, anthropic/claude-haiku-5.5.
#OPENROUTER_API_KEY=sk-or-v1-XXX
#OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
#OPENROUTER_MODEL=
Expand Down
16 changes: 9 additions & 7 deletions CLAUDE.md

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions scripts/test-vision.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* Quick probe: send one receipt image to a single vision provider and print the raw answer.
* Uses the bot's own clients, base URLs, models and request shape (chatRequestParams), so it
* tests exactly what production sends. Override a model inline, e.g.
* Uses the bot's own clients, base URLs, models and request helpers (chatRequestParams
* and hfRequestParams), so it tests the production request shape. Override a model inline, e.g.
* CLAUDE_VISION_MODEL=claude-haiku-5-5 bun scripts/test-vision.ts receipt.jpg claude
*/
import { readFileSync } from 'node:fs';
import OpenAI from 'openai';
import { env } from '../src/config/env';
import { claudeClient, hfClient, zaiClient } from '../src/services/ai/clients';
import { withClaudeCredentials } from '../src/services/ai/claude-client-pool';
import { chatRequestParams } from '../src/services/ai/streaming';
import { chatRequestParams, hfRequestParams } from '../src/services/ai/streaming';

const PROVIDERS: Record<string, { client: () => OpenAI; model: string; anthropic: boolean }> = {
claude: { client: claudeClient, model: env.CLAUDE_VISION_MODEL, anthropic: true },
Expand Down Expand Up @@ -43,6 +43,7 @@ say('---');
try {
const t0 = Date.now();
const client = provider.client();
const budget = chatRequestParams(provider.model, 2000, 0.1, provider.anthropic);
const params: OpenAI.ChatCompletionCreateParamsNonStreaming = {
model: provider.model,
messages: [
Expand All @@ -57,7 +58,8 @@ try {
],
},
],
...chatRequestParams(provider.model, 2000, 0.1, provider.anthropic),
...budget,
...(providerName === 'hf' ? hfRequestParams(provider.model, budget.max_tokens) : {}),
};
const response = provider.anthropic
? await withClaudeCredentials(
Expand Down
15 changes: 12 additions & 3 deletions scripts/verify-ai-chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import OpenAI from 'openai';
import { env } from '../src/config/env';
import { claudeClient, hfClient, openrouterClient, zaiClient } from '../src/services/ai/clients';
import { withClaudeCredentials } from '../src/services/ai/claude-client-pool';
import { chatRequestParams } from '../src/services/ai/streaming';
import { chatRequestParams, hfRequestParams } from '../src/services/ai/streaming';

interface SlotSpec {
chain: 'smart' | 'fast' | 'ocr';
Expand All @@ -28,6 +28,8 @@ interface SlotSpec {
vision: boolean;
/** Anthropic request shape, same as the Claude slots in streaming.ts: no `temperature` (400), per-model thinking. */
anthropic?: boolean;
/** HF-only thinking controls, matching the runtime slots. */
hf?: boolean;
}

const SLOTS: SlotSpec[] = [
Expand Down Expand Up @@ -57,6 +59,7 @@ const SLOTS: SlotSpec[] = [
client: hfClient,
model: env.HF_MODEL,
vision: false,
hf: true,
},
...(env.OPENROUTER_API_KEY &&
env.OPENROUTER_MODEL &&
Expand Down Expand Up @@ -97,6 +100,7 @@ const SLOTS: SlotSpec[] = [
client: hfClient,
model: env.HF_FAST_MODEL,
vision: false,
hf: true,
},
...(env.OPENROUTER_API_KEY &&
env.OPENROUTER_FAST_MODEL &&
Expand Down Expand Up @@ -130,6 +134,7 @@ const SLOTS: SlotSpec[] = [
client: hfClient,
model: env.HF_VISION_MODEL,
vision: true,
hf: true,
},
];

Expand Down Expand Up @@ -200,11 +205,13 @@ async function testTextSlot(spec: SlotSpec): Promise<SlotResult> {
// before producing visible output. Give them headroom.
const isReasoning = /qwen3|deepseek-r1/i.test(spec.model);
const maxTokens = isReasoning ? 2048 : 256;
const budget = chatRequestParams(spec.model, maxTokens, 0.1, spec.anthropic ?? false);
try {
const response = await createCompletion(spec, {
model: spec.model,
messages: [{ role: 'user', content: TEXT_PROMPT }],
...chatRequestParams(spec.model, maxTokens, 0.1, spec.anthropic ?? false),
...budget,
...(spec.hf ? hfRequestParams(spec.model, budget.max_tokens) : {}),
});
const text = response.choices[0]?.message?.content?.trim() ?? '';
const ms = Date.now() - start;
Expand Down Expand Up @@ -238,6 +245,7 @@ async function testVisionSlot(spec: SlotSpec): Promise<SlotResult> {
error: 'no test image available (place a .jpg/.png in debug-images/)',
};
}
const budget = chatRequestParams(spec.model, 128, 0.1, spec.anthropic ?? false);
try {
const response = await createCompletion(spec, {
model: spec.model,
Expand All @@ -253,7 +261,8 @@ async function testVisionSlot(spec: SlotSpec): Promise<SlotResult> {
],
},
],
...chatRequestParams(spec.model, 128, 0.1, spec.anthropic ?? false),
...budget,
...(spec.hf ? hfRequestParams(spec.model, budget.max_tokens) : {}),
});
const text = response.choices[0]?.message?.content?.trim() ?? '';
const ms = Date.now() - start;
Expand Down
12 changes: 6 additions & 6 deletions src/config/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ function loadedConfig(result: LoadResult): {
}

describe('env config loading', () => {
it('defaults both text chains to z.ai → Claude → HF', () => {
it('defaults both text chains to Claude → z.ai → HF', () => {
const { smartChain, fastChain } = loadedConfig(loadEnv(REQUIRED_ENV));
const expected = {
order: ['zai', 'claude', 'hf'],
order: ['claude', 'zai', 'hf'],
fromEnv: false,
fallback: ['zai', 'claude', 'hf'],
fallback: ['claude', 'zai', 'hf'],
};
expect(smartChain).toEqual(expected);
expect(fastChain).toEqual(expected);
Expand Down Expand Up @@ -184,12 +184,12 @@ describe('env config loading', () => {

it('a chain order naming no known provider falls back to the default order', () => {
const { smartChain } = loadedConfig(
loadEnv({ ...REQUIRED_ENV, AI_SMART_CHAIN: 'groq,gemini' }),
loadEnv({ ...REQUIRED_ENV, AI_SMART_CHAIN: 'unknown,unsupported' }),
);
expect(smartChain).toEqual({
order: ['zai', 'claude', 'hf'],
order: ['claude', 'zai', 'hf'],
fromEnv: false,
fallback: ['zai', 'claude', 'hf'],
fallback: ['claude', 'zai', 'hf'],
});
});

Expand Down
10 changes: 5 additions & 5 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import { createLogger } from '../utils/logger';

const logger = createLogger('env');

/** Default order of the smart chain: z.ai → Claude → HF. Overridden by AI_SMART_CHAIN. */
/** Default order of the smart chain: Claude → z.ai → HF. Overridden by AI_SMART_CHAIN. */
export const DEFAULT_SMART_CHAIN: ProviderId[] = [...DEFAULT_PROVIDER_IDS];
/** Default order of the fast chain: z.ai → Claude → HF. Overridden by AI_FAST_CHAIN. */
/** Default order of the fast chain: Claude → z.ai → HF. Overridden by AI_FAST_CHAIN. */
export const DEFAULT_FAST_CHAIN: ProviderId[] = [...DEFAULT_PROVIDER_IDS];

/**
Expand Down Expand Up @@ -82,7 +82,7 @@ interface EnvConfig {
DATABASE_PATH: string;
ENCRYPTION_KEY: string; // empty string when not set — encryption features deactivate gracefully

// z.ai provider (kept ANTHROPIC_API_KEY name — it's the z.ai key); head of the default chains
// z.ai provider (kept ANTHROPIC_API_KEY name — it's the z.ai key); fallback after Claude
ANTHROPIC_API_KEY: string;
AI_BASE_URL: string;
AI_MODEL: string;
Expand All @@ -92,7 +92,7 @@ interface EnvConfig {
AI_SMART_CHAIN: ChainOrder;
AI_FAST_CHAIN: ChainOrder;

// Anthropic Claude (OpenAI-compat endpoint; optional key — second in smart/fast, OCR primary).
// Anthropic Claude (OpenAI-compat endpoint; optional key — primary in smart/fast/OCR).
// Without either key every Claude slot is skipped and OCR runs on HF vision alone.
CLAUDE_API_TOKEN: string;
CLAUDE_API_TOKEN2: string;
Expand Down Expand Up @@ -121,7 +121,7 @@ interface EnvConfig {
AI_TOOL_SCHEMA_USER_IDS: number[] | undefined;

// OpenRouter (optional; named in a chain order to be used, skipped without key and model).
// One key fronts DeepSeek / GLM / Claude; the model id is the OpenRouter one, e.g. `deepseek/deepseek-v4.1-flash`.
// One key fronts DeepSeek / GLM / Claude; the model id is the OpenRouter one, e.g. `deepseek/deepseek-v4.1-flash-1017`.
OPENROUTER_API_KEY: string;
OPENROUTER_BASE_URL: string;
OPENROUTER_MODEL: string;
Expand Down
14 changes: 8 additions & 6 deletions src/services/ai/model-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ describe('MODEL_PRESETS', () => {
}
});

it('carries the exact OpenRouter ids probed on 2026-10-23', () => {
expect(findPreset('deepseek41-flash')?.models.openrouter).toBe('deepseek/deepseek-v4.1-flash');
it('carries the exact OpenRouter ids from the dated model catalogs', () => {
expect(findPreset('deepseek41-flash')?.models.openrouter).toBe(
'deepseek/deepseek-v4.1-flash-1017',
);
expect(findPreset('glm53-flash-low')?.models.openrouter).toBe('z-ai/glm-5.3-flash');
expect(findPreset('glm53-low')?.models.openrouter).toBe('z-ai/glm-5.3');
expect(findPreset('haiku')?.models.openrouter).toBe('anthropic/claude-haiku-5.5');
Expand Down Expand Up @@ -99,10 +101,10 @@ describe('isExcludedModel', () => {

it.each([
'glm-5.3-flash',
'deepseek/deepseek-v4.1-flash',
'deepseek/deepseek-v4.1-flash-1017',
'claude-sonnet-5-5',
'Qwen/Qwen3-235B-A22B',
'meta-llama/Llama-3.3-70B-Instruct',
'Qwen/Qwen3.8-2.4T-A95B:deepinfra',
'meta-llama/Llama-4-Scout-17B-16E-Instruct:deepinfra',
])('keeps %s', (model) => {
expect(isExcludedModel(model)).toBe(false);
});
Expand Down Expand Up @@ -175,7 +177,7 @@ describe('presetRequestParams', () => {

describe('computeCostMicroUsd', () => {
// deepseek41-flash on OpenRouter: $0.30 in / $1.20 out per million tokens = 0.30 / 1.20 µUSD per token.
const MODEL = 'deepseek/deepseek-v4.1-flash';
const MODEL = 'deepseek/deepseek-v4.1-flash-1017';

it('prices tokens at the list rate of the route, in whole micro-USD', () => {
expect(computeCostMicroUsd('openrouter', MODEL, 1_000_000, 1_000_000)).toBe(1_500_000);
Expand Down
9 changes: 5 additions & 4 deletions src/services/ai/model-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,18 @@ export interface ModelPreset {
}

/**
* Prices are OpenRouter's /models listing of 2026-10-23 (USD per token × 1e6). OpenRouter ids were
* checked against the same listing. `claude-haiku-5-5` on the `claude` route has no price here: the
* Prices are OpenRouter's /models listing (USD per token × 1e6), dated per row. The default date
* is 2026-10-23; the DeepSeek 1017 route was refreshed on 2026-11-13. The direct `claude` route has
* no price here: the
* direct Anthropic rate was not read, so its cost stays unknown.
*/
const PRICE_AS_OF = '2026-10-23';

export const MODEL_PRESETS: readonly ModelPreset[] = [
{
id: 'deepseek41-flash',
models: { openrouter: 'deepseek/deepseek-v4.1-flash' },
priceUsdPerMTok: { in: 0.3, out: 1.2, asOf: PRICE_AS_OF, via: 'openrouter' },
models: { openrouter: 'deepseek/deepseek-v4.1-flash-1017' },
priceUsdPerMTok: { in: 0.3, out: 1.2, asOf: '2026-11-13', via: 'openrouter' },
roles: ['executor'],
qualification: null,
},
Expand Down
2 changes: 1 addition & 1 deletion src/services/ai/provider-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ export const PROVIDER_IDS = ['zai', 'claude', 'hf', 'openrouter'] as const;
export type ProviderId = (typeof PROVIDER_IDS)[number];

/** The providers of the default chain orders; `openrouter` is optional and in none of them. */
export const DEFAULT_PROVIDER_IDS: readonly ProviderId[] = ['zai', 'claude', 'hf'];
export const DEFAULT_PROVIDER_IDS: readonly ProviderId[] = ['claude', 'zai', 'hf'];
Loading
Loading