mod8-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +239 -0
  4. package/bin/mod8.js +2 -0
  5. package/dist/cli.js +302 -0
  6. package/dist/commands/addProvider.js +105 -0
  7. package/dist/commands/all.js +158 -0
  8. package/dist/commands/chat.js +855 -0
  9. package/dist/commands/config.js +29 -0
  10. package/dist/commands/devAuthStatus.js +34 -0
  11. package/dist/commands/devHostAsk.js +51 -0
  12. package/dist/commands/devHostSystem.js +15 -0
  13. package/dist/commands/devResolve.js +54 -0
  14. package/dist/commands/devSimulate.js +235 -0
  15. package/dist/commands/devWorkAsk.js +55 -0
  16. package/dist/commands/intentRouting.js +280 -0
  17. package/dist/commands/keys.js +55 -0
  18. package/dist/commands/list.js +27 -0
  19. package/dist/commands/login.js +147 -0
  20. package/dist/commands/logout.js +17 -0
  21. package/dist/commands/prompt.js +63 -0
  22. package/dist/commands/providers.js +30 -0
  23. package/dist/commands/verify.js +5 -0
  24. package/dist/input/compose.js +37 -0
  25. package/dist/input/files.js +49 -0
  26. package/dist/input/stdin.js +14 -0
  27. package/dist/providers/anthropic.js +115 -0
  28. package/dist/providers/displayName.js +25 -0
  29. package/dist/providers/errorHints.js +175 -0
  30. package/dist/providers/generic.js +331 -0
  31. package/dist/providers/genericChat.js +265 -0
  32. package/dist/providers/google.js +63 -0
  33. package/dist/providers/hostSystem.js +173 -0
  34. package/dist/providers/index.js +38 -0
  35. package/dist/providers/mock.js +87 -0
  36. package/dist/providers/modelResolution.js +42 -0
  37. package/dist/providers/openai.js +75 -0
  38. package/dist/providers/pricing.js +47 -0
  39. package/dist/providers/proxy.js +148 -0
  40. package/dist/providers/registry.js +196 -0
  41. package/dist/providers/types.js +1 -0
  42. package/dist/providers/workSystem.js +33 -0
  43. package/dist/storage/auth.js +65 -0
  44. package/dist/storage/config.js +35 -0
  45. package/dist/storage/keys.js +59 -0
  46. package/dist/storage/providers.js +337 -0
  47. package/dist/storage/sessions.js +150 -0
  48. package/dist/types.js +9 -0
  49. package/dist/util/debug.js +79 -0
  50. package/dist/util/errors.js +157 -0
  51. package/dist/util/prompt.js +111 -0
  52. package/dist/util/secrets.js +110 -0
  53. package/dist/util/text.js +53 -0
  54. package/dist/util/time.js +25 -0
  55. package/dist/verify/runner.js +437 -0
  56. package/package.json +69 -0
  57. package/specs/all-mode.yaml +44 -0
  58. package/specs/behavior/auto-fallback.yaml +49 -0
  59. package/specs/behavior/bare-name-routing.yaml +223 -0
  60. package/specs/behavior/bare-paste-confirm.yaml +125 -0
  61. package/specs/behavior/env-var-respected.yaml +108 -0
  62. package/specs/behavior/error-fidelity.yaml +92 -0
  63. package/specs/behavior/error-hints.yaml +160 -0
  64. package/specs/behavior/fresh-vs-resume.yaml +94 -0
  65. package/specs/behavior/fuzzy-match.yaml +208 -0
  66. package/specs/behavior/host-self-knowledge-fresh.yaml +66 -0
  67. package/specs/behavior/intent-no-mismatch.yaml +115 -0
  68. package/specs/behavior/login-logout.yaml +97 -0
  69. package/specs/behavior/no-model-allowlist.yaml +80 -0
  70. package/specs/behavior/paste-key.yaml +342 -0
  71. package/specs/behavior/provider-switching.yaml +186 -0
  72. package/specs/behavior/providers-json-respected.yaml +106 -0
  73. package/specs/behavior/self-knowledge.yaml +119 -0
  74. package/specs/behavior/stress-session.yaml +226 -0
  75. package/specs/behavior/switch-back-when-failing.yaml +90 -0
  76. package/specs/behavior/work-character.yaml +109 -0
  77. package/specs/chat-meta.yaml +349 -0
  78. package/specs/chat-startup.yaml +148 -0
  79. package/specs/chat.yaml +91 -0
  80. package/specs/config.yaml +42 -0
  81. package/specs/install.yaml +112 -0
  82. package/specs/keys.yaml +81 -0
  83. package/specs/one-shot.yaml +65 -0
  84. package/specs/pipe-and-files.yaml +40 -0
  85. package/specs/providers.yaml +172 -0
  86. package/specs/sessions.yaml +115 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Host system prompt builder.
3
+ *
4
+ * The host (mod8) needs to know about itself so it can answer meta questions
5
+ * — "what providers do I have?", "how do I add one?", "what can you do?",
6
+ * "what's codex?" (a name the user gave a configured provider) — directly
7
+ * instead of pivoting to "tell me about your project."
8
+ *
9
+ * We assemble the prompt at chat startup with live data from the providers
10
+ * store, so the host can name the user's actual configured providers (with
11
+ * their custom names and models), not just a generic list.
12
+ */
13
+ import { listProviders } from '../storage/providers.js';
14
+ import { KNOWN_PROVIDERS } from './registry.js';
15
+ export async function readHostContext() {
16
+ const stored = await listProviders();
17
+ const configured = [];
18
+ for (const [id, entry] of Object.entries(stored)) {
19
+ configured.push({
20
+ id,
21
+ name: entry.name,
22
+ defaultModel: entry.defaultModel,
23
+ apiType: entry.apiType,
24
+ custom: !!entry.custom,
25
+ });
26
+ }
27
+ return { configured };
28
+ }
29
+ export function buildHostSystem(ctx) {
30
+ const builtInCount = KNOWN_PROVIDERS.length;
31
+ const configuredCount = ctx.configured.length;
32
+ const configuredBlock = configuredCount === 0
33
+ ? ' (none yet — user must run `mod8 keys set <id>` or `mod8 add-provider` first)'
34
+ : ctx.configured
35
+ .map((p) => ` - id: "${p.id}" · name: "${p.name}" · model: ${p.defaultModel} · api: ${p.apiType}${p.custom ? ' (custom)' : ''}`)
36
+ .join('\n');
37
+ // Build a "if user says X, they likely mean configured provider Y" lookup
38
+ // hint so the host can recognize nicknames at a glance.
39
+ const nicknameHints = configuredCount === 0
40
+ ? ''
41
+ : '\nName-match hint: if the user mentions any of these terms, they are very likely referring to one of the configured providers above:\n' +
42
+ ctx.configured
43
+ .map((p) => ` - "${p.id}", "${p.name}" → provider id "${p.id}"`)
44
+ .join('\n');
45
+ const builtInList = KNOWN_PROVIDERS.map((p) => p.id).join(', ');
46
+ return `You are mod8, a multi-provider LLM CLI. You are the tool itself, talking to the user from inside your own chat REPL. You are NOT a generic chatbot, and you are NOT helping the user build some other software — mod8 IS the software, and you have full information about it (listed below).
47
+
48
+ # ABSOLUTE RULE — read first
49
+
50
+ You DO have details about your own setup. They are spelled out below. NEVER say "I don't have info about what's powering me" or "I don't have details about my setup" or anything like that — those are lies, and they will get you replaced. If a user asks ANY question about mod8, providers, operators, platforms, models, connections, configuration, or commands, you answer FROM THE FACTS BELOW — not by deflecting.
51
+
52
+ # Mod8 vocabulary — these words always mean meta about mod8
53
+
54
+ If the user's message contains any of these words/phrases, they are asking about MOD8 ITSELF, not about a separate project they're building:
55
+ provider, providers, operator, operators, platform, platforms, model, models, connected, connection, configured, key, keys, BYOK, /providers, --all, compare, switch, "use <something>", "ask <something>", "talk to <something>", chat, REPL, session, sessions.
56
+
57
+ When you see these words, the question is META. Answer from the facts below. Do NOT pivot to "tell me about your project."
58
+
59
+ # What mod8 is
60
+
61
+ mod8 is a command-line tool for chatting with large language models from the terminal. BYOK (bring your own key): the user's API keys live locally in ~/.config/mod8/providers.json (mode 0600). Nothing is sent anywhere except directly to the providers they've configured. There is no mod8 server, no telemetry.
62
+
63
+ You (the planning side, "host") run on Anthropic Sonnet. The other side ("work") runs on whichever provider the user picks — defaults to Anthropic Opus, displayed as "claude".
64
+
65
+ # Providers configured RIGHT NOW (in this session) — ${configuredCount} configured
66
+
67
+ ${configuredBlock}
68
+ ${nicknameHints}
69
+
70
+ # Built-in provider templates the user can add a key for (${builtInCount} total)
71
+
72
+ ${builtInList}. Plus any OpenAI-compatible API via \`mod8 add-provider\` — paste a key, mod8 detects the format, asks for missing details (id, base URL, default model), saves it.
73
+
74
+ # Commands the user can run
75
+
76
+ From the shell:
77
+ - \`mod8 "..."\` — one-shot to the configured default provider
78
+ - \`mod8 -c "..."\` — one-shot to Anthropic
79
+ - \`mod8 -o "..."\` — one-shot to OpenAI
80
+ - \`mod8 -g "..."\` — one-shot to Gemini
81
+ - \`mod8 --all "..."\` — fan out to every configured provider, side-by-side
82
+ - \`mod8 keys set <id>\` — save an API key for a built-in provider
83
+ - \`mod8 keys list\` — see which providers are configured
84
+ - \`mod8 keys remove <id>\` — drop a key
85
+ - \`mod8 add-provider\` — interactive flow to register any provider
86
+ - \`mod8 providers\` — detailed view of configured providers
87
+ - \`mod8 new\` — start a fresh chat session
88
+ - \`mod8 list\` — see saved sessions
89
+ - \`mod8 resume <id>\` — continue a session
90
+ - \`mod8 verify\` — run the built-in self-test suite
91
+
92
+ In chat (right here, while talking to you):
93
+ - "go", "let's work", "let me talk to claude" — switches to work mode (Anthropic Opus by default)
94
+ - "use <id>", "ask <id>", "switch to <id>", "talk to <id>", "let me talk to <id>" — switches work mode to a specific configured provider (the CLI handles all these phrasings directly, you don't emit a token)
95
+ - The CLI also accepts common nicknames as aliases: "gpt"/"chatgpt" → openai, "claude"/"sonnet"/"opus" → anthropic, "gemini"/"bard" → google, "grok" → xai, "llama" → groq.
96
+ - "compare all: <prompt>", "ask everyone: <prompt>", "/compare <prompt>" — fan out the next turn across every configured provider, side-by-side
97
+ - "/providers" — list configured providers
98
+ - "/clear" — wipe the current session's history
99
+ - "/exit" — quit
100
+ - "/mod8" or "@mod8" (from inside work mode) — return to host
101
+ - esc — interrupt streaming mid-response
102
+
103
+ # Adding / changing / updating API keys — INLINE, never via the CLI
104
+
105
+ mod8 has an inline paste-key flow. When the user says ANY of these (in any phrasing):
106
+ - "add a key" / "paste a key" / "save my key" / "register a key"
107
+ - "change the google key" / "update my anthropic key" / "replace the openai key"
108
+ - "rotate google key" / "swap the gemini key" / "renew my key"
109
+ - "let me add gemini" / "i need to update my key" / "lets change the key"
110
+
111
+ …the CLI's deterministic intent matcher catches it BEFORE you see it and arms a consent flow that asks the user to paste their key right here in chat. The CLI then masks the key in the transcript, saves it locally to ~/.config/mod8/providers.json, and confirms.
112
+
113
+ If for some reason you DO see one of these messages (the matcher missed a rare phrasing), respond with EXACTLY:
114
+
115
+ "Sure — paste your new key in your next message. I'll mask it in chat and save it locally."
116
+
117
+ Then STOP. Do NOT emit any handoff token. Do NOT mention "mod8 keys set <id>". Do NOT tell the user to "run this in your shell". Do NOT show a code block with a CLI command.
118
+
119
+ The CLI command "mod8 keys set <id>" exists, but it is for users who are NOT currently in chat. Inside chat, the inline paste flow is always the right answer. Telling someone in chat "run this in your shell" is wrong twice: it makes them leave, and it ignores the inline path that already works.
120
+
121
+ # How to behave (READ CAREFULLY)
122
+
123
+ Before each response, ask yourself: does the user's message use any mod8 vocabulary (see list above), or could it be interpreted as a question about mod8? If yes — even partially yes — this is a META question. Answer from the facts above.
124
+
125
+ Examples of META questions you must answer directly (DON'T pivot):
126
+ - "what is mod8?" / "what can you do?" / "what's this?"
127
+ - "how many operators / providers / platforms / models are you connected to?"
128
+ - "what providers do I have?" / "what platforms are configured?"
129
+ - "how do I add a new provider?" / "how do I switch?" / "how do I compare?"
130
+ - "what commands are there?" / "what's /providers?"
131
+ - A bare provider id or name from the configured list ("codex", "anthropic", "groq", etc.) — they're talking about THAT provider. Confirm what you know, ask if they want to use it.
132
+ - Any question that uses "you" / "your" referring to mod8 ("how many operators do you connect to?", "what's powering you?", "which models do you have?").
133
+
134
+ When the user wants to plan a real task or build something OUTSIDE of mod8 (their own software project — a web app, a script, a feature), THAT is when planning behavior kicks in: ask 1-2 clarifying questions, suggest approaches, then hand off to work mode when they're ready.
135
+
136
+ DEFAULT BIAS: when a question is ambiguous, default to META (treat it as about mod8), NOT to "their project." A meta-answer is always recoverable; pivoting to "tell me about your project" is the bug we are explicitly trying to prevent.
137
+
138
+ If you genuinely cannot tell, ASK ONCE to clarify (e.g., "are you asking about mod8 itself, or about a project you're working on?"). Do NOT assume "their project" silently.
139
+
140
+ Keep responses to 1-3 sentences — direct, friendly, not chatty. For meta answers, short bullet lists are fine.
141
+
142
+ # How to hand off to work mode
143
+
144
+ When the user clearly wants real work done — coding, writing, generating — respond with a one-sentence acknowledgement, then end your message with the literal token <SWITCH_TO_WORK>. Don't explain the token. Just append it on a new line at the end. The CLI strips it from the visible reply and switches modes for the user's next turn.
145
+
146
+ When to hand off (any of these, or anything equivalent — be generous):
147
+ - explicit triggers: "go", "let's go", "let's work", "let's build", "switch"
148
+ - asking for the worker: "let me talk to claude", "I want claude", "give me claude", "claude please"
149
+ - ready to act: "I'm ready", "go ahead", "do it", "build it", "code it", "write it", "let's start"
150
+
151
+ If the user names a specific provider ("use deepseek", "ask grok"), the CLI handles the switch directly — DON'T emit the token; just answer normally or briefly confirm.
152
+
153
+ If the user is asking a meta question, exploring, clarifying, asking how-to — DON'T emit the token. Stay engaged.
154
+
155
+ Never refuse a hand-off.
156
+
157
+ Don't reveal which underlying model powers you. You are mod8.
158
+
159
+ # CRITICAL — never lie about which provider is being switched to
160
+
161
+ The <SWITCH_TO_WORK> token ALWAYS lands on claude (Anthropic Opus, the default work model). It cannot route to any other provider. The CLI's intent router (a separate, deterministic component) handles routing to specific providers — it runs BEFORE you do, so by the time YOU see the user's message, any "use codex" / "talk with grok" intent has either already been routed or wasn't recognized.
162
+
163
+ Therefore, when you emit <SWITCH_TO_WORK>:
164
+ - It is OK to say "switching to claude", "let me hand you off to claude", "going to work mode".
165
+ - It is NEVER OK to say "switching to codex", "switching to gpt", "switching to grok", "switching to <anything except claude>". That would be a lie — the token only lands on claude.
166
+
167
+ If the user asked for a specific provider but the CLI didn't route them (e.g. they typed something the intent matcher missed), do NOT emit <SWITCH_TO_WORK> and pretend you switched to that provider. Instead, tell the user the exact phrasing that works:
168
+
169
+ Wrong: "Switching you to codex now! <SWITCH_TO_WORK>"
170
+ Right: "I can't route to codex from this message — type 'use codex' or 'talk to codex' and I'll switch you, or I can hand you off to claude with 'go'."
171
+
172
+ The user-facing banner is generated by the CLI based on the actual routing — your spoken text MUST agree with what actually happens, or the user will see two different things and lose trust.`;
173
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Public entry point for getting a provider client by id.
3
+ *
4
+ * Routing rules:
5
+ * 1. MOD8_MOCK=1 → mock (test path; handled inside buildProviderClient)
6
+ * 2. auth.json → proxy client for {anthropic, openai, google, deepseek};
7
+ * custom OpenAI-compat ids fall through to (3)
8
+ * 3. otherwise → local BYOK from providers.json (current behavior)
9
+ *
10
+ * Used by one-shot (`mod8 -c/-o/-g/-d`), `--all`, and config-set default
11
+ * routing. The chat REPL uses streamProviderChat from genericChat.ts
12
+ * directly — that module mirrors the same routing.
13
+ */
14
+ import { buildProviderClient } from './generic.js';
15
+ import { readAuth, effectiveProxyUrl } from '../storage/auth.js';
16
+ import { makeProxyClient, toProxyProviderId } from './proxy.js';
17
+ export async function getProviderClient(id) {
18
+ if (process.env.MOD8_MOCK === '1')
19
+ return buildProviderClient(id);
20
+ const auth = await readAuth();
21
+ if (auth) {
22
+ const proxyId = toProxyProviderId(id);
23
+ if (proxyId) {
24
+ return makeProxyClient({
25
+ proxyUrl: effectiveProxyUrl(auth),
26
+ mod8Key: auth.mod8Key,
27
+ providerId: proxyId,
28
+ });
29
+ }
30
+ // Custom providers (mistral / groq / openrouter / xai / custom): the
31
+ // proxy doesn't carry them yet. Fall back to local providers.json so
32
+ // the user isn't blocked.
33
+ }
34
+ return buildProviderClient(id);
35
+ }
36
+ export async function authedSession() {
37
+ return readAuth();
38
+ }
@@ -0,0 +1,87 @@
1
+ import { priceFor } from './pricing.js';
2
+ const RESPONSE_FALLBACK = 'Mock five-word reply here now.';
3
+ const KNOWN_RESPONSES = {
4
+ anthropic: 'Hello! Five words exactly here.',
5
+ openai: 'Five quick words from GPT.',
6
+ google: 'Hi from Gemini, five words.',
7
+ deepseek: 'Five quick words from DeepSeek.',
8
+ mistral: 'Mistral five-word mock reply here.',
9
+ groq: 'Groq five-word mock reply here.',
10
+ xai: 'Grok five-word mock reply here.',
11
+ openrouter: 'OpenRouter five-word mock reply here.',
12
+ together: 'Together five-word mock reply here.',
13
+ };
14
+ const KNOWN_MODELS = {
15
+ anthropic: 'claude-sonnet-4-6',
16
+ openai: 'gpt-4o',
17
+ google: 'gemini-2.0-flash',
18
+ deepseek: 'deepseek-chat',
19
+ mistral: 'mistral-large-latest',
20
+ groq: 'llama-3.3-70b-versatile',
21
+ xai: 'grok-2-latest',
22
+ openrouter: 'openai/gpt-4o-mini',
23
+ together: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
24
+ };
25
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
+ export function mockProvider(id) {
27
+ const model = KNOWN_MODELS[id] ?? `${id}-mock-1`;
28
+ const responseText = KNOWN_RESPONSES[id] ?? RESPONSE_FALLBACK;
29
+ const buildUsage = (latencyMs, inputTokens, outputTokens) => ({
30
+ inputTokens,
31
+ outputTokens,
32
+ latencyMs,
33
+ model,
34
+ costUsd: priceFor(model, inputTokens, outputTokens),
35
+ });
36
+ const checkFail = () => {
37
+ if (process.env.MOD8_MOCK_FAIL === id) {
38
+ throw new Error(`Mock failure: ${id} provider intentionally failed`);
39
+ }
40
+ const errType = process.env.MOD8_MOCK_ERROR;
41
+ if (errType && (process.env.MOD8_MOCK_ERROR_PROVIDER ?? id) === id) {
42
+ switch (errType) {
43
+ case '401':
44
+ throw new Error('401 Unauthorized: invalid_api_key');
45
+ case '429':
46
+ throw new Error('429 Too Many Requests: rate_limit_exceeded');
47
+ case 'network':
48
+ throw new Error('fetch failed: ENOTFOUND api.example.com');
49
+ case 'quota':
50
+ throw new Error('insufficient credits on your account');
51
+ case 'timeout':
52
+ throw new Error('Request timed out after 60s');
53
+ case 'model':
54
+ throw new Error('model `nope-1` does not exist');
55
+ }
56
+ }
57
+ };
58
+ return {
59
+ id,
60
+ defaultModel: model,
61
+ async call(prompt) {
62
+ const delay = 200 + Math.random() * 400;
63
+ await sleep(delay);
64
+ checkFail();
65
+ const inputTokens = Math.max(1, Math.floor(prompt.length / 4));
66
+ const outputTokens = 8;
67
+ const text = process.env.MOD8_MOCK_ECHO === '1' ? `[${id}] received:\n${prompt}` : responseText;
68
+ return {
69
+ text,
70
+ ...buildUsage(Math.round(delay), inputTokens, outputTokens),
71
+ };
72
+ },
73
+ async *stream(prompt) {
74
+ const start = Date.now();
75
+ await sleep(120 + Math.random() * 180);
76
+ checkFail();
77
+ for (let i = 0; i < responseText.length; i++) {
78
+ yield { type: 'text', delta: responseText[i] };
79
+ await sleep(8 + Math.random() * 12);
80
+ }
81
+ const latencyMs = Date.now() - start;
82
+ const inputTokens = Math.max(1, Math.floor(prompt.length / 4));
83
+ const outputTokens = 8;
84
+ yield { type: 'done', usage: buildUsage(latencyMs, inputTokens, outputTokens) };
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Single source of truth for picking the model for a provider call.
3
+ *
4
+ * Resolution priority (matches the user-facing contract — env > config >
5
+ * template default):
6
+ *
7
+ * 1. opts.model — explicit per-call override (rare; used by
8
+ * compare flow when targeting specific models)
9
+ * 2. MOD8_<ID>_MODEL env — quick override without editing providers.json,
10
+ * case-insensitive on the env var name
11
+ * 3. entry.defaultModel — the value the user wrote into providers.json
12
+ * 4. (none) — providers without a default fail loudly so the
13
+ * caller can surface a useful error
14
+ *
15
+ * NEVER silently substitute a different model from any internal allowlist —
16
+ * if the user wrote "gemini-2.5-flash" we send "gemini-2.5-flash" to the
17
+ * provider, and let the provider decide whether that's valid.
18
+ */
19
+ /** Build the env var name for a given provider id ("google" → "MOD8_GOOGLE_MODEL"). */
20
+ export function envVarForProvider(providerId) {
21
+ const sanitized = providerId.toUpperCase().replace(/[^A-Z0-9]/g, '_');
22
+ return `MOD8_${sanitized}_MODEL`;
23
+ }
24
+ /** Read the env override for a provider id, or undefined if unset/empty. */
25
+ export function envModelFor(providerId) {
26
+ const v = process.env[envVarForProvider(providerId)];
27
+ return v && v.length > 0 ? v : undefined;
28
+ }
29
+ export function resolveModel(providerId, optsModel, entryDefaultModel) {
30
+ const envVar = envVarForProvider(providerId);
31
+ if (optsModel && optsModel.length > 0) {
32
+ return { model: optsModel, source: 'opts', envVar };
33
+ }
34
+ const envModel = envModelFor(providerId);
35
+ if (envModel) {
36
+ return { model: envModel, source: 'env', envVar };
37
+ }
38
+ if (entryDefaultModel && entryDefaultModel.length > 0) {
39
+ return { model: entryDefaultModel, source: 'providers.json', envVar };
40
+ }
41
+ return { model: '', source: 'none', envVar };
42
+ }
@@ -0,0 +1,75 @@
1
+ import OpenAI from 'openai';
2
+ import { getKey } from '../storage/keys.js';
3
+ import { priceFor } from './pricing.js';
4
+ const DEFAULT_MODEL = 'gpt-4o';
5
+ async function buildClient() {
6
+ const apiKey = process.env.OPENAI_API_KEY ?? (await getKey('openai'));
7
+ if (!apiKey) {
8
+ throw new Error('No OpenAI key configured. Run: mod8 keys set openai, or set OPENAI_API_KEY.');
9
+ }
10
+ return new OpenAI({ apiKey });
11
+ }
12
+ export const openaiProvider = {
13
+ id: 'openai',
14
+ defaultModel: DEFAULT_MODEL,
15
+ async call(prompt, opts = {}) {
16
+ const client = await buildClient();
17
+ const model = opts.model ?? process.env.MOD8_OPENAI_MODEL ?? DEFAULT_MODEL;
18
+ const start = Date.now();
19
+ const res = await client.chat.completions.create({
20
+ model,
21
+ messages: [{ role: 'user', content: prompt }],
22
+ max_tokens: opts.maxTokens ?? 1024,
23
+ });
24
+ const latencyMs = Date.now() - start;
25
+ const text = res.choices[0]?.message?.content ?? '';
26
+ const inputTokens = res.usage?.prompt_tokens ?? 0;
27
+ const outputTokens = res.usage?.completion_tokens ?? 0;
28
+ const actualModel = res.model ?? model;
29
+ return {
30
+ text,
31
+ inputTokens,
32
+ outputTokens,
33
+ costUsd: priceFor(actualModel, inputTokens, outputTokens),
34
+ latencyMs,
35
+ model: actualModel,
36
+ };
37
+ },
38
+ async *stream(prompt, opts = {}) {
39
+ const client = await buildClient();
40
+ const model = opts.model ?? process.env.MOD8_OPENAI_MODEL ?? DEFAULT_MODEL;
41
+ const start = Date.now();
42
+ const stream = await client.chat.completions.create({
43
+ model,
44
+ messages: [{ role: 'user', content: prompt }],
45
+ max_tokens: opts.maxTokens ?? 1024,
46
+ stream: true,
47
+ stream_options: { include_usage: true },
48
+ });
49
+ let inputTokens = 0;
50
+ let outputTokens = 0;
51
+ let actualModel = model;
52
+ for await (const chunk of stream) {
53
+ const delta = chunk.choices[0]?.delta?.content;
54
+ if (delta)
55
+ yield { type: 'text', delta };
56
+ if (chunk.usage) {
57
+ inputTokens = chunk.usage.prompt_tokens ?? 0;
58
+ outputTokens = chunk.usage.completion_tokens ?? 0;
59
+ }
60
+ if (chunk.model)
61
+ actualModel = chunk.model;
62
+ }
63
+ const latencyMs = Date.now() - start;
64
+ yield {
65
+ type: 'done',
66
+ usage: {
67
+ inputTokens,
68
+ outputTokens,
69
+ latencyMs,
70
+ model: actualModel,
71
+ costUsd: priceFor(actualModel, inputTokens, outputTokens),
72
+ },
73
+ };
74
+ },
75
+ };
@@ -0,0 +1,47 @@
1
+ const PRICING = {
2
+ // Anthropic (Claude 4.x family — list prices)
3
+ 'claude-opus-4-7': { inputPerMtok: 15, outputPerMtok: 75 },
4
+ 'claude-opus-4': { inputPerMtok: 15, outputPerMtok: 75 },
5
+ 'claude-sonnet-4-6': { inputPerMtok: 3, outputPerMtok: 15 },
6
+ 'claude-sonnet-4-5': { inputPerMtok: 3, outputPerMtok: 15 },
7
+ 'claude-sonnet-4': { inputPerMtok: 3, outputPerMtok: 15 },
8
+ 'claude-haiku-4-5': { inputPerMtok: 1, outputPerMtok: 5 },
9
+ 'claude-haiku-4': { inputPerMtok: 1, outputPerMtok: 5 },
10
+ // OpenAI
11
+ 'gpt-4o': { inputPerMtok: 2.5, outputPerMtok: 10 },
12
+ 'gpt-4o-mini': { inputPerMtok: 0.15, outputPerMtok: 0.6 },
13
+ 'gpt-4.1': { inputPerMtok: 2, outputPerMtok: 8 },
14
+ 'gpt-4.1-mini': { inputPerMtok: 0.4, outputPerMtok: 1.6 },
15
+ // Google
16
+ 'gemini-2.0-flash': { inputPerMtok: 0.075, outputPerMtok: 0.3 },
17
+ 'gemini-2.5-flash': { inputPerMtok: 0.075, outputPerMtok: 0.3 },
18
+ 'gemini-2.5-pro': { inputPerMtok: 1.25, outputPerMtok: 5 },
19
+ };
20
+ export function priceFor(model, inputTokens, outputTokens) {
21
+ let p = PRICING[model];
22
+ if (!p) {
23
+ // Longest matching prefix
24
+ let bestKey;
25
+ for (const key of Object.keys(PRICING)) {
26
+ if (model.startsWith(key) && (!bestKey || key.length > bestKey.length)) {
27
+ bestKey = key;
28
+ }
29
+ }
30
+ if (bestKey)
31
+ p = PRICING[bestKey];
32
+ }
33
+ if (!p)
34
+ return 0;
35
+ return (inputTokens / 1_000_000) * p.inputPerMtok + (outputTokens / 1_000_000) * p.outputPerMtok;
36
+ }
37
+ export function formatCost(usd) {
38
+ if (usd === 0)
39
+ return '$0';
40
+ if (usd < 0.001)
41
+ return '<$0.001';
42
+ if (usd < 0.01)
43
+ return `$${usd.toFixed(4)}`;
44
+ if (usd < 1)
45
+ return `$${usd.toFixed(3)}`;
46
+ return `$${usd.toFixed(2)}`;
47
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * ProxyClient — talks to the mod8 hosted proxy (mod8-proxy on Cloud Run)
3
+ * over SSE. Same ProviderClient surface as local clients so the rest of
4
+ * the CLI doesn't have to branch.
5
+ *
6
+ * Wire format (matches proxy/src/server.ts):
7
+ * POST /v1/chat
8
+ * Authorization: Bearer <sk-mod8-...>
9
+ * { provider, model, messages: [{role,content}], maxTokens?, system? }
10
+ *
11
+ * SSE events:
12
+ * data: { "type": "text", "delta": "..." }
13
+ * data: { "type": "done", "tokensIn": N, "tokensOut": M,
14
+ * "rawCostMicros": X, "chargedMicros": Y,
15
+ * "balanceAfterMicros": Z, "chargeApplied": true }
16
+ * data: { "type": "error", "error": "..." }
17
+ *
18
+ * Charged amount uses chargedMicros (raw + 15% markup), not raw — the
19
+ * user's bill, not the provider's bill.
20
+ */
21
+ /** CLI provider id → proxy provider id. Custom OpenAI-compat providers
22
+ * (mistral/groq/openrouter/xai/together/custom) don't run through the
23
+ * proxy — they fall back to local providers.json. */
24
+ export function toProxyProviderId(id) {
25
+ if (id === 'anthropic' || id === 'openai' || id === 'google' || id === 'deepseek') {
26
+ return id;
27
+ }
28
+ return null;
29
+ }
30
+ const DEFAULT_MODEL = {
31
+ anthropic: 'claude-sonnet-4-6',
32
+ openai: 'gpt-4o',
33
+ google: 'gemini-2.5-flash',
34
+ deepseek: 'deepseek-chat',
35
+ };
36
+ export function makeProxyClient(opts) {
37
+ const id = opts.providerId;
38
+ const fallbackModel = opts.defaultModel ?? DEFAULT_MODEL[id];
39
+ async function* runStream(prompt, callOpts) {
40
+ const model = callOpts.model ?? fallbackModel;
41
+ const start = Date.now();
42
+ const resp = await fetch(`${opts.proxyUrl}/v1/chat`, {
43
+ method: 'POST',
44
+ headers: {
45
+ Authorization: `Bearer ${opts.mod8Key}`,
46
+ 'Content-Type': 'application/json',
47
+ },
48
+ body: JSON.stringify({
49
+ provider: id,
50
+ model,
51
+ messages: [{ role: 'user', content: prompt }],
52
+ ...(callOpts.maxTokens !== undefined ? { maxTokens: callOpts.maxTokens } : {}),
53
+ }),
54
+ });
55
+ if (!resp.ok) {
56
+ const detail = await resp.text().catch(() => '');
57
+ throw new Error(`mod8 proxy: ${resp.status} ${resp.statusText}${detail ? ` — ${trim(detail)}` : ''}`);
58
+ }
59
+ if (!resp.body) {
60
+ throw new Error('mod8 proxy: empty response body');
61
+ }
62
+ const reader = resp.body.getReader();
63
+ const decoder = new TextDecoder();
64
+ let buf = '';
65
+ let inputTokens = 0;
66
+ let outputTokens = 0;
67
+ let chargedMicros = 0;
68
+ let actualModel = model;
69
+ let sawDone = false;
70
+ while (true) {
71
+ const { done, value } = await reader.read();
72
+ if (done)
73
+ break;
74
+ buf += decoder.decode(value, { stream: true });
75
+ let idx;
76
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
77
+ const chunk = buf.slice(0, idx);
78
+ buf = buf.slice(idx + 2);
79
+ for (const line of chunk.split('\n')) {
80
+ if (!line.startsWith('data: '))
81
+ continue;
82
+ let ev;
83
+ try {
84
+ ev = JSON.parse(line.slice(6));
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ if (ev.type === 'text') {
90
+ yield { type: 'text', delta: ev.delta };
91
+ }
92
+ else if (ev.type === 'done') {
93
+ inputTokens = ev.tokensIn;
94
+ outputTokens = ev.tokensOut;
95
+ chargedMicros = ev.chargedMicros;
96
+ sawDone = true;
97
+ }
98
+ else if (ev.type === 'error') {
99
+ throw new Error(`mod8 proxy: ${ev.error}`);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ if (!sawDone) {
105
+ throw new Error('mod8 proxy: stream ended without a done event');
106
+ }
107
+ yield {
108
+ type: 'done',
109
+ usage: {
110
+ inputTokens,
111
+ outputTokens,
112
+ latencyMs: Date.now() - start,
113
+ model: actualModel,
114
+ costUsd: chargedMicros / 1_000_000,
115
+ },
116
+ };
117
+ }
118
+ return {
119
+ id,
120
+ defaultModel: fallbackModel,
121
+ async call(prompt, callOpts = {}) {
122
+ let text = '';
123
+ let inputTokens = 0;
124
+ let outputTokens = 0;
125
+ let costUsd = 0;
126
+ let model = fallbackModel;
127
+ let latencyMs = 0;
128
+ for await (const ev of runStream(prompt, callOpts)) {
129
+ if (ev.type === 'text')
130
+ text += ev.delta;
131
+ else if (ev.type === 'done') {
132
+ inputTokens = ev.usage.inputTokens;
133
+ outputTokens = ev.usage.outputTokens;
134
+ costUsd = ev.usage.costUsd;
135
+ model = ev.usage.model;
136
+ latencyMs = ev.usage.latencyMs;
137
+ }
138
+ }
139
+ return { text, inputTokens, outputTokens, costUsd, latencyMs, model };
140
+ },
141
+ async *stream(prompt, callOpts = {}) {
142
+ yield* runStream(prompt, callOpts);
143
+ },
144
+ };
145
+ }
146
+ function trim(s) {
147
+ return s.length > 200 ? s.slice(0, 200) + '…' : s;
148
+ }