@ouro.bot/cli 0.1.0-alpha.954 → 0.1.0-alpha.956

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.
@@ -361,6 +361,16 @@ async function collectRuntimeAuthCredentials(input, deps) {
361
361
  }
362
362
  return credentials;
363
363
  }
364
+ if (input.provider === "anthropic" && input.apiKey) {
365
+ (0, runtime_1.emitNervesEvent)({
366
+ component: "daemon",
367
+ event: "daemon.auth_anthropic_api_key",
368
+ message: "storing anthropic API key for runtime auth",
369
+ meta: { agentName: input.agentName, hasBaseUrl: !!input.baseUrl },
370
+ });
371
+ writeAuthProgress(input, "using the supplied anthropic API key...");
372
+ return { apiKey: input.apiKey.trim(), ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}) };
373
+ }
364
374
  if (input.provider === "anthropic") {
365
375
  (0, runtime_1.emitNervesEvent)({
366
376
  component: "daemon",
@@ -414,12 +424,16 @@ async function collectRuntimeAuthCredentials(input, deps) {
414
424
  }
415
425
  return creds;
416
426
  }
427
+ /** True when the record holds `required` or one of the provider's alternative field sets in full. */
428
+ function hasRequiredProviderCredentials(provider, record) {
429
+ const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
430
+ return [requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((key) => !!record[key]));
431
+ }
417
432
  async function resolveHatchCredentials(input) {
418
433
  const credentials = { ...(input.credentials ?? {}) };
419
434
  // If all required fields are already provided, return as-is
420
435
  const cred = credentials;
421
- const missing = identity_1.PROVIDER_CREDENTIALS[input.provider].required.some((key) => !cred[key]);
422
- if (!missing)
436
+ if (hasRequiredProviderCredentials(input.provider, cred))
423
437
  return credentials;
424
438
  // Try the full auth flow (wraps collectRuntimeAuthCredentials + writes secrets)
425
439
  if (input.runAuthFlow) {
@@ -431,7 +445,7 @@ async function resolveHatchCredentials(input) {
431
445
  });
432
446
  Object.assign(credentials, result.credentials);
433
447
  /* v8 ignore next 3 -- branch: auth flow always fills all required fields in production @preserve */
434
- if (!identity_1.PROVIDER_CREDENTIALS[input.provider].required.some((key) => !credentials[key])) {
448
+ if (hasRequiredProviderCredentials(input.provider, credentials)) {
435
449
  return credentials;
436
450
  }
437
451
  }
@@ -235,7 +235,11 @@ function getMinimaxConfig() {
235
235
  }
236
236
  function getAnthropicConfig() {
237
237
  const raw = readProviderConfig("anthropic");
238
- return { setupToken: typeof raw.setupToken === "string" ? raw.setupToken : "" };
238
+ return {
239
+ setupToken: typeof raw.setupToken === "string" ? raw.setupToken : "",
240
+ ...(typeof raw.apiKey === "string" && raw.apiKey ? { apiKey: raw.apiKey } : {}),
241
+ ...(typeof raw.baseUrl === "string" && raw.baseUrl ? { baseUrl: raw.baseUrl } : {}),
242
+ };
239
243
  }
240
244
  function getOpenAICodexConfig() {
241
245
  const raw = readProviderConfig("openai-codex");
@@ -5648,8 +5648,24 @@ async function executeProviderRefresh(command, deps) {
5648
5648
  deps.writeStdout(message);
5649
5649
  return message;
5650
5650
  }
5651
+ /** Reads an API key from a file (or /dev/stdin) so it never appears in argv. */
5652
+ function readApiKeyFile(filePath) {
5653
+ let raw;
5654
+ try {
5655
+ raw = fs.readFileSync(filePath, "utf8");
5656
+ }
5657
+ catch (error) {
5658
+ /* v8 ignore next -- fs.readFileSync only throws Error objects @preserve */
5659
+ throw new Error(`could not read --api-key-file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
5660
+ }
5661
+ const key = raw.trim();
5662
+ if (!key)
5663
+ throw new Error(`--api-key-file ${filePath} is empty.`);
5664
+ return key;
5665
+ }
5651
5666
  async function executeAuthRun(command, deps) {
5652
5667
  const provider = command.provider ?? (0, auth_flow_1.readAgentConfigForAgent)(command.agent, deps.bundlesRoot).config.humanFacing.provider;
5668
+ const apiKey = command.apiKeyFile ? readApiKeyFile(command.apiKeyFile) : undefined;
5653
5669
  /* v8 ignore next -- tests always inject runAuthFlow; default is for production @preserve */
5654
5670
  const authRunner = deps.runAuthFlow ?? (await Promise.resolve().then(() => __importStar(require("../auth/auth-flow")))).runRuntimeAuthFlow;
5655
5671
  const progress = createHumanCommandProgress(deps, "auth");
@@ -5662,6 +5678,8 @@ async function executeAuthRun(command, deps) {
5662
5678
  promptInput: deps.promptInput,
5663
5679
  promptSecret: deps.promptSecret,
5664
5680
  onProgress: (message) => progress.updateDetail(message),
5681
+ ...(apiKey ? { apiKey } : {}),
5682
+ ...(command.baseUrl ? { baseUrl: command.baseUrl } : {}),
5665
5683
  });
5666
5684
  }
5667
5685
  catch (error) {
@@ -8231,6 +8249,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
8231
8249
  if (command.kind === "a2a.card" || command.kind === "a2a.onboard" || command.kind === "a2a.serve" || command.kind === "a2a.escalation" || command.kind === "a2a.delegatedCommands") {
8232
8250
  return executeA2ACommand(command, deps);
8233
8251
  }
8252
+ if (command.kind === "gateway.serve") {
8253
+ const { runCreditGatewayCommand } = await Promise.resolve().then(() => __importStar(require("../../gateway/server")));
8254
+ return runCreditGatewayCommand(command, {
8255
+ writeStdout: deps.writeStdout,
8256
+ ...(deps.waitForShutdownSignal ? { waitForStop: deps.waitForShutdownSignal } : {}),
8257
+ });
8258
+ }
8234
8259
  // ── provider commands (local, no daemon socket needed) ──
8235
8260
  if (command.kind === "provider.use") {
8236
8261
  return executeProviderUse(command, deps);
@@ -259,8 +259,8 @@ exports.COMMAND_REGISTRY = {
259
259
  },
260
260
  auth: {
261
261
  category: "Auth",
262
- description: "Set up or verify agent credentials",
263
- usage: "ouro auth [verify] [--agent <name>] [--provider <provider>]",
262
+ description: "Set up or verify agent credentials. For Anthropic API-key mode (for example through a credit gateway), pass --api-key-file <path> (use /dev/stdin to pipe it) and optionally --base-url <url>; the key is never taken from the command line.",
263
+ usage: "ouro auth [verify] [--agent <name>] [--provider <provider>] [--api-key-file <path> [--base-url <url>]]",
264
264
  example: "ouro auth",
265
265
  subcommands: ["verify"],
266
266
  },
@@ -358,6 +358,13 @@ exports.COMMAND_REGISTRY = {
358
358
  example: "ouro session list",
359
359
  subcommands: ["list"],
360
360
  },
361
+ gateway: {
362
+ category: "System",
363
+ description: "Run the credit gateway: one master key for agents, upstream Anthropic API keys spent in a fixed order",
364
+ usage: "ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>]",
365
+ example: "ouro gateway serve --keys-dir /mnt/user/appdata/credit-gateway/keys",
366
+ subcommands: ["serve"],
367
+ },
361
368
  mcp: {
362
369
  category: "System",
363
370
  description: "Interact with MCP servers",
@@ -497,6 +504,11 @@ const SUBCOMMAND_HELP = {
497
504
  usage: "ouro a2a onboard [--agent <name>] (--card-url <url>|--did <did:key> --name <name>) [--trust <level>] [--name <name>]",
498
505
  example: "ouro a2a onboard --agent <agent> --card-url https://peer.example/.well-known/agent-card.json --trust friend",
499
506
  },
507
+ "gateway serve": {
508
+ description: "Run the credit gateway in the foreground. It accepts the master key from <keys-dir>/master.key and forwards each request to the Anthropic API with the first upstream *.key (by file name) whose credits are not exhausted; a key whose credit balance is too low is skipped for the cooldown (default 60 minutes). GET /health needs no key.",
509
+ usage: "ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]",
510
+ example: "ouro gateway serve --keys-dir /mnt/user/appdata/credit-gateway/keys --port 8787",
511
+ },
500
512
  "a2a serve": {
501
513
  description: "Run this agent's local A2A JSON-RPC sense server",
502
514
  usage: "ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
@@ -254,6 +254,7 @@ function usage() {
254
254
  " ouro mailbox [--json]",
255
255
  " ouro -v|--version",
256
256
  " ouro auth [--agent <name>] [--provider <provider>]",
257
+ " ouro auth --agent <name> --provider anthropic --api-key-file <path> [--base-url <url>]",
257
258
  " ouro account ensure [--agent <name>] [--owner-email <email> --source <label>|--no-delegated-source] [--rotate-missing-mail-keys]",
258
259
  " ouro connect [providers|perplexity|embeddings|teams|bluebubbles|mail|voice|a2a|telegram|workbench] [--agent <name>] [--owner-email <email> --source <label>|--no-delegated-source] [--rotate-missing-mail-keys]",
259
260
  " ouro mail import-mbox --file <path> [--owner-email <email>] [--source <label>] [--agent <name>] [--foreground]",
@@ -303,6 +304,7 @@ function usage() {
303
304
  " ouro a2a card [--agent <name>] [--base-url <url>] [--json]",
304
305
  " ouro a2a onboard [--agent <name>] (--card-url <url>|--did <did:key> --name <name>) [--trust <level>] [--name <name>]",
305
306
  " ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
307
+ " ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]",
306
308
  " ouro a2a identity [--identity-file <path>] [--json]",
307
309
  " ouro a2a message --to <card-url> --text <text> [--context <id>] [--delegated] [--identity-file <path>] [--json]",
308
310
  " ouro a2a outbox list --to <card-url> [--since <cursor>] [--identity-file <path>] [--json]",
@@ -834,6 +836,8 @@ function parseAuthCommand(args) {
834
836
  }
835
837
  const { agent, rest } = extractAgentFlag(args);
836
838
  let provider;
839
+ let apiKeyFile;
840
+ let baseUrl;
837
841
  for (let i = 0; i < rest.length; i += 1) {
838
842
  if (rest[i] === "--provider") {
839
843
  const value = rest[i + 1];
@@ -843,11 +847,33 @@ function parseAuthCommand(args) {
843
847
  i += 1;
844
848
  continue;
845
849
  }
850
+ // The API key itself is read from a file (or /dev/stdin), never from argv, so it stays out of
851
+ // shell history and process listings.
852
+ if (rest[i] === "--api-key-file" || rest[i] === "--base-url") {
853
+ const value = rest[i + 1];
854
+ if (!value || value.startsWith("--"))
855
+ throw new Error(`${rest[i]} requires a value.`);
856
+ if (rest[i] === "--api-key-file")
857
+ apiKeyFile = value;
858
+ else
859
+ baseUrl = value;
860
+ i += 1;
861
+ continue;
862
+ }
863
+ }
864
+ if ((apiKeyFile || baseUrl) && provider !== "anthropic") {
865
+ throw new Error("--api-key-file and --base-url are only for --provider anthropic (API-key mode).");
846
866
  }
867
+ if (baseUrl && !apiKeyFile)
868
+ throw new Error("--base-url requires --api-key-file.");
869
+ if (baseUrl && !/^https?:\/\//.test(baseUrl))
870
+ throw new Error("--base-url must be an http(s) URL.");
847
871
  return {
848
872
  kind: "auth.run",
849
873
  ...(agent ? { agent } : {}),
850
874
  ...(provider ? { provider } : {}),
875
+ ...(apiKeyFile ? { apiKeyFile } : {}),
876
+ ...(baseUrl ? { baseUrl } : {}),
851
877
  };
852
878
  }
853
879
  function isVaultUnlockStoreKind(value) {
@@ -1936,6 +1962,61 @@ function parseA2ACommand(args) {
1936
1962
  }
1937
1963
  throw new Error("Usage: ouro a2a card|onboard|serve|message|identity|outbox|escalation|delegated-commands ...");
1938
1964
  }
1965
+ const GATEWAY_SERVE_USAGE = "Usage: ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]";
1966
+ function parseGatewayCommand(args) {
1967
+ const [sub, ...rest] = args;
1968
+ if (sub !== "serve")
1969
+ throw new Error(GATEWAY_SERVE_USAGE);
1970
+ let keysDir;
1971
+ let host;
1972
+ let port;
1973
+ let statePath;
1974
+ let upstream;
1975
+ let cooldownMinutes;
1976
+ for (let i = 0; i < rest.length; i += 1) {
1977
+ const flag = rest[i];
1978
+ const value = rest[i + 1];
1979
+ if (!value || value.startsWith("--"))
1980
+ throw new Error(GATEWAY_SERVE_USAGE);
1981
+ i += 1;
1982
+ if (flag === "--keys-dir")
1983
+ keysDir = value;
1984
+ else if (flag === "--host")
1985
+ host = value;
1986
+ else if (flag === "--state")
1987
+ statePath = value;
1988
+ else if (flag === "--upstream") {
1989
+ if (!/^https?:\/\//.test(value))
1990
+ throw new Error("--upstream must be an http(s) URL");
1991
+ upstream = value;
1992
+ }
1993
+ else if (flag === "--port") {
1994
+ const parsed = Number(value);
1995
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535)
1996
+ throw new Error("Gateway port must be 0-65535");
1997
+ port = parsed;
1998
+ }
1999
+ else if (flag === "--cooldown-minutes") {
2000
+ const parsed = Number(value);
2001
+ if (!Number.isFinite(parsed) || parsed <= 0)
2002
+ throw new Error("--cooldown-minutes must be a positive number");
2003
+ cooldownMinutes = parsed;
2004
+ }
2005
+ else
2006
+ throw new Error(GATEWAY_SERVE_USAGE);
2007
+ }
2008
+ if (!keysDir)
2009
+ throw new Error(GATEWAY_SERVE_USAGE);
2010
+ return {
2011
+ kind: "gateway.serve",
2012
+ keysDir,
2013
+ ...(host ? { host } : {}),
2014
+ ...(port !== undefined ? { port } : {}),
2015
+ ...(statePath ? { statePath } : {}),
2016
+ ...(upstream ? { upstream } : {}),
2017
+ ...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}),
2018
+ };
2019
+ }
1939
2020
  function parseConfigCommand(args) {
1940
2021
  const { agent, rest: afterAgent } = extractAgentFlag(args);
1941
2022
  const { facing, rest: cleaned } = extractFacingFlag(afterAgent);
@@ -3118,6 +3199,8 @@ function parseOuroCommand(args) {
3118
3199
  return parseFriendCommand(args.slice(1));
3119
3200
  if (head === "a2a")
3120
3201
  return parseA2ACommand(args.slice(1));
3202
+ if (head === "gateway")
3203
+ return parseGatewayCommand(args.slice(1));
3121
3204
  if (head === "config")
3122
3205
  return parseConfigCommand(args.slice(1));
3123
3206
  if (head === "mcp")
@@ -69,7 +69,7 @@ var autonomy_budget_2 = require("./autonomy-budget");
69
69
  Object.defineProperty(exports, "normalizeHabitPaidTurnsPerDay", { enumerable: true, get: function () { return autonomy_budget_2.normalizeHabitPaidTurnsPerDay; } });
70
70
  /** Single source of truth for per-provider credential field names, env var mappings, and prompt labels. */
71
71
  exports.PROVIDER_CREDENTIALS = {
72
- anthropic: { required: ["setupToken"], envVars: { ANTHROPIC_API_KEY: "setupToken" }, promptLabels: { setupToken: "Anthropic setup-token" } },
72
+ anthropic: { required: ["setupToken"], alternativeRequired: [["apiKey"]], envVars: { ANTHROPIC_API_KEY: "setupToken" }, promptLabels: { setupToken: "Anthropic setup-token" } },
73
73
  "openai-codex": { required: ["oauthAccessToken"], envVars: { OPENAI_API_KEY: "oauthAccessToken" }, promptLabels: { oauthAccessToken: "OpenAI Codex OAuth token" } },
74
74
  azure: { required: ["apiKey", "endpoint", "deployment"], envVars: { AZURE_OPENAI_API_KEY: "apiKey", AZURE_OPENAI_KEY: "apiKey", AZURE_OPENAI_ENDPOINT: "endpoint", AZURE_OPENAI_DEPLOYMENT: "deployment" }, promptLabels: { apiKey: "Azure API key", endpoint: "Azure endpoint", deployment: "Azure deployment" } },
75
75
  minimax: { required: ["apiKey"], envVars: { MINIMAX_API_KEY: "apiKey" }, promptLabels: { apiKey: "MiniMax API key" } },
@@ -59,8 +59,8 @@ const VAULT_ITEM_PREFIX = "providers/";
59
59
  exports.PROVIDER_CREDENTIAL_POOL_NOT_LOADED_ERROR = "provider credentials have not been loaded from vault";
60
60
  const PROVIDER_FIELD_SPLITS = {
61
61
  anthropic: {
62
- credentials: ["setupToken", "refreshToken", "expiresAt"],
63
- config: [],
62
+ credentials: ["setupToken", "refreshToken", "expiresAt", "apiKey"],
63
+ config: ["baseUrl"],
64
64
  },
65
65
  "openai-codex": {
66
66
  credentials: ["oauthAccessToken", "refreshToken", "expiresAt"],
@@ -157,7 +157,8 @@ function hasEmptyCredentials(provider, config) {
157
157
  if (hasManagedIdentity)
158
158
  return false;
159
159
  }
160
- return identity_1.PROVIDER_CREDENTIALS[provider].required.some((key) => !record[key]);
160
+ const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
161
+ return ![requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((key) => !!record[key]));
161
162
  }
162
163
  function createProviderRuntimeForConfig(provider, config, options = {}) {
163
164
  // Use the same provider defaults as auth switch and hatch so verification
@@ -225,7 +225,16 @@ function mergeAnthropicToolArguments(current, partial) {
225
225
  }
226
226
  return current + partial;
227
227
  }
228
+ // An org whose prepaid API credits are used up gets HTTP 400 (or 402) with this phrase. That is a
229
+ // usage limit, not a malformed request, so failover tells the human the right thing.
230
+ const ANTHROPIC_CREDIT_LOW_PHRASE = "credit balance is too low";
231
+ function isAnthropicCreditExhausted(error) {
232
+ const status = error.status;
233
+ return (status === 400 || status === 402) && error.message.toLowerCase().includes(ANTHROPIC_CREDIT_LOW_PHRASE);
234
+ }
228
235
  function classifyAnthropicError(error) {
236
+ if (isAnthropicCreditExhausted(error))
237
+ return "usage-limit";
229
238
  return (0, error_classification_1.classifyHttpError)(error, {
230
239
  isAuthFailure: isAnthropicAuthFailure,
231
240
  isServerError: (e) => e.status === 529,
@@ -236,9 +245,10 @@ function isAnthropicAuthFailure(error) {
236
245
  return (lower.includes("oauth authentication") ||
237
246
  lower.includes("authentication failed") ||
238
247
  lower.includes("unauthorized") ||
239
- lower.includes("invalid api key"));
248
+ lower.includes("invalid api key") ||
249
+ lower.includes("invalid x-api-key"));
240
250
  }
241
- async function streamAnthropicMessages(client, model, request) {
251
+ async function streamAnthropicMessages(client, model, request, options = { claudeCodePreamble: true }) {
242
252
  const { system, messages } = toAnthropicMessages(request.messages);
243
253
  const additionalSystemParts = request.messages
244
254
  .filter((message) => message.role === "system")
@@ -260,13 +270,14 @@ async function streamAnthropicMessages(client, model, request) {
260
270
  // prompt when using OAuth setup tokens (sk-ant-oat01). Without it, Opus/Sonnet
261
271
  // 4.6 requests are rejected with 400. This is the API's validation that the
262
272
  // token is being used by a Claude Code client.
263
- const preambleText = "You are Claude Code, Anthropic's official CLI for Claude.";
273
+ // API-key mode (a plain API key, for example through a credit gateway) needs no such block.
274
+ const preambleText = options.claudeCodePreamble ? "You are Claude Code, Anthropic's official CLI for Claude." : "";
264
275
  if (request.systemPrompt) {
265
276
  // Structured SystemPrompt: merge preamble + stable prefix into one cached block,
266
277
  // volatile suffix as a separate uncached block.
267
278
  const stableBlock = {
268
279
  type: "text",
269
- text: preambleText + "\n\n" + request.systemPrompt.stable,
280
+ text: preambleText ? preambleText + "\n\n" + request.systemPrompt.stable : request.systemPrompt.stable,
270
281
  cache_control: { type: "ephemeral" },
271
282
  };
272
283
  params.system = [
@@ -279,9 +290,11 @@ async function streamAnthropicMessages(client, model, request) {
279
290
  }
280
291
  else if (system) {
281
292
  // Fallback: no structured prompt, extract from messages (legacy path)
282
- params.system = [{ type: "text", text: preambleText }, { type: "text", text: system }];
293
+ params.system = preambleText
294
+ ? [{ type: "text", text: preambleText }, { type: "text", text: system }]
295
+ : [{ type: "text", text: system }];
283
296
  }
284
- else {
297
+ else if (preambleText) {
285
298
  params.system = [{ type: "text", text: preambleText }];
286
299
  }
287
300
  if (anthropicTools.length > 0)
@@ -441,6 +454,48 @@ async function streamAnthropicMessages(client, model, request) {
441
454
  ...(settleFinalization ? { settleFinalization } : {}),
442
455
  };
443
456
  }
457
+ const ANTHROPIC_PING_MODEL = "claude-haiku-4-5-20251001";
458
+ /**
459
+ * API-key mode: a plain Anthropic API key, optionally against another base URL such as a credit
460
+ * gateway. No Claude Code identity headers, no Claude Code system preamble and no token refresh;
461
+ * prompt caching, thinking, tools and streaming work as in setup-token mode.
462
+ */
463
+ function createAnthropicApiKeyRuntime(model, apiKey, baseUrl) {
464
+ const modelCaps = (0, model_capabilities_1.getModelCapabilities)(model);
465
+ const capabilities = new Set();
466
+ if (modelCaps.reasoningEffort)
467
+ capabilities.add("reasoning-effort");
468
+ // authToken: null keeps the SDK from also sending an ANTHROPIC_AUTH_TOKEN from the environment.
469
+ const client = new sdk_1.default({ apiKey, authToken: null, baseURL: baseUrl, maxRetries: 0 });
470
+ (0, runtime_1.emitNervesEvent)({
471
+ component: "engine",
472
+ event: "engine.anthropic_api_key_mode",
473
+ message: "anthropic provider using API-key mode",
474
+ meta: { provider: "anthropic", customBaseUrl: baseUrl !== undefined },
475
+ });
476
+ return {
477
+ id: "anthropic",
478
+ model,
479
+ client,
480
+ capabilities,
481
+ supportedReasoningEfforts: modelCaps.reasoningEffort,
482
+ resetTurnState(_messages) {
483
+ // Anthropic request payload is derived from canonical messages each turn.
484
+ },
485
+ appendToolOutput(_callId, _output) {
486
+ // Anthropic uses canonical messages for tool_result tracking.
487
+ },
488
+ streamTurn(request) {
489
+ return streamAnthropicMessages(client, model, request, { claudeCodePreamble: false });
490
+ },
491
+ async ping(signal) {
492
+ await client.messages.create({ model: ANTHROPIC_PING_MODEL, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal });
493
+ },
494
+ classifyError(error) {
495
+ return classifyAnthropicError(error);
496
+ },
497
+ };
498
+ }
444
499
  function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.getAnthropicConfig)()) {
445
500
  (0, runtime_1.emitNervesEvent)({
446
501
  component: "engine",
@@ -448,6 +503,9 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
448
503
  message: "anthropic provider init",
449
504
  meta: { provider: "anthropic" },
450
505
  });
506
+ const apiKey = anthropicConfig.apiKey?.trim();
507
+ if (apiKey)
508
+ return createAnthropicApiKeyRuntime(model, apiKey, anthropicConfig.baseUrl?.trim() || undefined);
451
509
  if (!anthropicConfig.setupToken) {
452
510
  throw new Error(getAnthropicReauthGuidance("provider 'anthropic' is selected but anthropic.setupToken is missing in the agent vault."));
453
511
  }
@@ -509,7 +567,7 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
509
567
  /* v8 ignore start -- ping: tested via provider-ping.test.ts @preserve */
510
568
  async ping(signal) {
511
569
  const freshClient = await ensureClient();
512
- await freshClient.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal, headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" } });
570
+ await freshClient.messages.create({ model: ANTHROPIC_PING_MODEL, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal, headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" } });
513
571
  },
514
572
  /* v8 ignore stop */
515
573
  /* v8 ignore next 3 -- delegation: classification logic tested via classifyAnthropicError @preserve */
@@ -108,7 +108,8 @@ function isProviderCredentialRecord(value) {
108
108
  const provider = value.provider;
109
109
  const canonicalCredentials = (0, provider_credentials_1.splitProviderCredentialFields)(provider, credentials).credentials;
110
110
  const canonicalConfig = (0, provider_credentials_1.splitProviderCredentialFields)(provider, config).config;
111
- if (!identity_1.PROVIDER_CREDENTIALS[provider].required.every((field) => (isPresentCredentialValue(canonicalCredentials[field]) || isPresentCredentialValue(canonicalConfig[field]))))
111
+ const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
112
+ if (![requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((field) => (isPresentCredentialValue(canonicalCredentials[field]) || isPresentCredentialValue(canonicalConfig[field])))))
112
113
  return false;
113
114
  const provenance = value.provenance;
114
115
  if (!isRecord(provenance))
@@ -106,6 +106,11 @@ const BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES = new Set([
106
106
  "local-visible",
107
107
  "delivered",
108
108
  ]);
109
+ // A reply whose final send keeps failing stays pending for recovery this many
110
+ // times, then gets a terminal receipt so one message cannot drain the budget.
111
+ const BLUEBUBBLES_REPLY_DELIVERY_MAX_ATTEMPTS = 3;
112
+ // A timed-out send may still land, so wait before checking the chat and retrying.
113
+ const BLUEBUBBLES_REPLY_RETRY_DELAY_MS = 10 * 60_000;
109
114
  exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS = 15 * 60_000;
110
115
  function pruneBlueBubblesSemanticObservationGenerations(nowMs = Date.now()) {
111
116
  let pruned = 0;
@@ -1622,8 +1627,12 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1622
1627
  const turnStage = createBlueBubblesTurnStage(event.chat.sessionKey);
1623
1628
  let finalTransportInvoked = false;
1624
1629
  let finalTransportAccepted = false;
1630
+ let spokeBeforeFinalTransport = false;
1625
1631
  const outboundRuntimeConfig = (0, config_1.getBlueBubblesConfig)();
1626
- const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, () => (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, liveTurnId), {
1632
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, () => {
1633
+ spokeBeforeFinalTransport = true;
1634
+ (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, liveTurnId);
1635
+ }, {
1627
1636
  admitOutbound: async () => (!options.lifecycleSignal?.aborted
1628
1637
  && await (0, latest_turn_1.awaitDeliveryAdmission)(options.latestTurnCapability)
1629
1638
  && !options.lifecycleSignal?.aborted),
@@ -1976,6 +1985,18 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1976
1985
  reason: error instanceof Error ? error.message : String(error),
1977
1986
  },
1978
1987
  });
1988
+ if (spokeBeforeFinalTransport) {
1989
+ // The friend already saw part of this turn; a retry would repeat it.
1990
+ const failed = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(options.currentIngressEvidence.captureKeyHash));
1991
+ if (failed) {
1992
+ (0, outbound_state_1.markBlueBubblesOutboundPendingManualVerification)({
1993
+ agentRoot: (0, identity_1.getAgentRoot)(),
1994
+ recordId: failed.recordId,
1995
+ decidedAt: new Date().toISOString(),
1996
+ reason: "mid-turn speech was visible before the final send failed",
1997
+ });
1998
+ }
1999
+ }
1979
2000
  return {
1980
2001
  handled: true,
1981
2002
  notifiedAgent: false,
@@ -2406,6 +2427,18 @@ function writeCapturedBlueBubblesHandled(agentName, capture, outcome, detailCode
2406
2427
  function blueBubblesInboundReplyIdempotencyKey(captureKeyHash) {
2407
2428
  return `bluebubbles-inbound-reply:${captureKeyHash}`;
2408
2429
  }
2430
+ async function blueBubblesFailedReplyIsVisible(deps, chat, record) {
2431
+ const since = Date.parse(record.createdAt);
2432
+ const recent = await deps.createClient().listRecentMessages?.({
2433
+ chatGuid: chat.chatGuid,
2434
+ limit: 25,
2435
+ }) ?? [];
2436
+ return recent.some((message) => (message.kind === "message"
2437
+ && message.fromMe
2438
+ && message.timestamp >= since
2439
+ && message.chat.chatGuid === chat.chatGuid
2440
+ && (0, outbound_state_1.blueBubblesOutboundTextMatches)(record, message.text)));
2441
+ }
2409
2442
  function blueBubblesRecoveredOutboundOutcome(record) {
2410
2443
  return BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES.has(record.status)
2411
2444
  ? "message_completed"
@@ -2520,7 +2553,13 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2520
2553
  return result;
2521
2554
  }
2522
2555
  const durableOutbound = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(captured.capture.keyHash));
2523
- if (durableOutbound) {
2556
+ const retryFailedReply = durableOutbound?.status === "failed"
2557
+ && (durableOutbound.failedAttempts ?? 0) < BLUEBUBBLES_REPLY_DELIVERY_MAX_ATTEMPTS;
2558
+ if (retryFailedReply
2559
+ && Date.now() < Date.parse(durableOutbound.failedAt ?? durableOutbound.updatedAt) + BLUEBUBBLES_REPLY_RETRY_DELAY_MS) {
2560
+ return { handled: true, notifiedAgent: false, kind: captured.normalized.kind, reason: "delivery_failed" };
2561
+ }
2562
+ if (durableOutbound && !retryFailedReply) {
2524
2563
  const outcome = blueBubblesRecoveredOutboundOutcome(durableOutbound);
2525
2564
  (0, runtime_1.emitNervesEvent)({
2526
2565
  level: "warn",
@@ -2627,11 +2666,74 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2627
2666
  orientationConversationKind: canonicalEvent.chat.isGroup ? "group" : "one_to_one",
2628
2667
  publishAcceptedReceipt,
2629
2668
  };
2669
+ let failedReplyVisible = false;
2670
+ if (retryFailedReply) {
2671
+ try {
2672
+ failedReplyVisible = await blueBubblesFailedReplyIsVisible(resolvedDeps, canonicalEvent.chat, durableOutbound);
2673
+ }
2674
+ catch (error) {
2675
+ // Without the chat history a retry could reply twice; try again later.
2676
+ (0, runtime_1.emitNervesEvent)({
2677
+ level: "warn",
2678
+ component: "senses",
2679
+ event: "senses.bluebubbles_reply_retry_deferred",
2680
+ message: "deferred a bluebubbles reply retry because recent chat messages could not be read",
2681
+ meta: {
2682
+ messageGuid: captured.normalized.messageGuid,
2683
+ reason: error instanceof Error ? error.message : String(error),
2684
+ },
2685
+ });
2686
+ return { handled: true, notifiedAgent: false, kind: captured.normalized.kind, reason: "delivery_failed" };
2687
+ }
2688
+ }
2689
+ if (failedReplyVisible) {
2690
+ // The send that timed out reached the chat after all; do not reply twice.
2691
+ (0, runtime_1.emitNervesEvent)({
2692
+ level: "warn",
2693
+ component: "senses",
2694
+ event: "senses.bluebubbles_inbound_reply_recovery_suppressed",
2695
+ message: "suppressed bluebubbles reply retry because a reply is already in the chat",
2696
+ meta: { messageGuid: captured.normalized.messageGuid, recordId: durableOutbound.recordId },
2697
+ });
2698
+ publishTerminalReceipt("message_completed", "reply_visible_after_failed_send");
2699
+ semanticHandlingCompleted = true;
2700
+ return { handled: true, notifiedAgent: false, kind: captured.normalized.kind, reason: "already_processed" };
2701
+ }
2630
2702
  result = await handleBlueBubblesNormalizedEvent(canonicalEvent, resolvedDeps, source, capturedOptions);
2631
2703
  // A budget or storm-breaker block defers the message; it was never seen.
2632
2704
  // Leave it pending so a later recovery pass retries it once the block lifts.
2633
2705
  if (result.reason === "autonomy_budget_blocked")
2634
2706
  return result;
2707
+ if (result.reason === "delivery_failed") {
2708
+ // The reply never reached the chat. Leave the message pending so a later
2709
+ // recovery pass sends it, until the per-message attempt cap is reached.
2710
+ let failed = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(captured.capture.keyHash));
2711
+ if (failed?.status === "failed") {
2712
+ if (retryFailedReply && failed.failedAttempts === durableOutbound.failedAttempts) {
2713
+ // The retry failed before it reached the transport; count it anyway.
2714
+ failed = (0, outbound_state_1.markBlueBubblesOutboundFailed)({
2715
+ agentRoot: (0, identity_1.getAgentRoot)(),
2716
+ recordId: failed.recordId,
2717
+ failedAt: new Date().toISOString(),
2718
+ reason: "retry failed before the final transport",
2719
+ });
2720
+ }
2721
+ /* v8 ignore next -- a failed record re-read after a send always carries its count @preserve */
2722
+ const failedAttempts = failed.failedAttempts ?? 0;
2723
+ if (failedAttempts < BLUEBUBBLES_REPLY_DELIVERY_MAX_ATTEMPTS)
2724
+ return result;
2725
+ (0, runtime_1.emitNervesEvent)({
2726
+ level: "error",
2727
+ component: "senses",
2728
+ event: "senses.bluebubbles_reply_delivery_abandoned",
2729
+ message: "gave up sending a bluebubbles reply after repeated transport failures",
2730
+ meta: { messageGuid: captured.normalized.messageGuid, failedAttempts },
2731
+ });
2732
+ publishTerminalReceipt("message_observed", "reply_delivery_retries_exhausted");
2733
+ semanticHandlingCompleted = true;
2734
+ return result;
2735
+ }
2736
+ }
2635
2737
  handledOutcome = result.notifiedAgent ? "message_completed" : "message_observed";
2636
2738
  }
2637
2739
  if (result.notifiedAgent) {