From 229fe53440f2f1b96918a5bb7e3a6b3610a52041 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:57 +0000 Subject: [PATCH 01/14] feat(otel): add reversible opt-in agent setup and wizard step --- __tests__/e2e/cli/otel.e2e.test.ts | 54 ++++ __tests__/hooks/configure-wizard.test.ts | 5 +- __tests__/hooks/daemon-download.test.ts | 5 +- __tests__/hooks/fp-home.test.ts | 1 + __tests__/hooks/otel.test.ts | 277 ++++++++++++++++++ bin/failproofai.mjs | 46 ++- bun.lock | 6 + package.json | 2 + src/hooks/cloud-introspect.ts | 3 + src/hooks/collector-config.ts | 11 +- src/hooks/configure-wizard.ts | 39 +++ src/hooks/fp-config.ts | 28 ++ src/hooks/fp-home.ts | 11 + src/hooks/integrations.ts | 2 +- src/hooks/otel-cli.ts | 340 +++++++++++++++++++++++ src/hooks/otel-writers.ts | 187 +++++++++++++ src/hooks/safe-config-write.ts | 6 +- 17 files changed, 1014 insertions(+), 9 deletions(-) create mode 100644 __tests__/e2e/cli/otel.e2e.test.ts create mode 100644 __tests__/hooks/otel.test.ts create mode 100644 src/hooks/otel-cli.ts create mode 100644 src/hooks/otel-writers.ts diff --git a/__tests__/e2e/cli/otel.e2e.test.ts b/__tests__/e2e/cli/otel.e2e.test.ts new file mode 100644 index 000000000..8cb4ca7f5 --- /dev/null +++ b/__tests__/e2e/cli/otel.e2e.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { afterAll, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "fpai-otel-bundle-")); +const home = join(root, "home"); +const binary = join(root, "cli.mjs"); +const repo = resolve(__dirname, "../../.."); +// Like the installed tarball, external runtime deps sit next to the bundle. +symlinkSync(join(repo, "node_modules"), join(root, "node_modules"), "dir"); +mkdirSync(join(home, ".failproofai"), { recursive: true }); +mkdirSync(join(home, ".codex"), { recursive: true }); +writeFileSync(join(home, ".failproofai/credentials.json"), JSON.stringify({ + ingest: { url: "http://127.0.0.1:1/v1/events", key: "capture-test" }, +})); +const original = '# original\nmodel = "test"\n[projects."/work"]\ntrust_level = "trusted"\n'; +writeFileSync(join(home, ".codex/config.toml"), original); +const built = spawnSync("bun", ["build", "--target=node", "--format=esm", `--outfile=${binary}`, + "bin/failproofai.mjs", "--external", "posthog-node", "--external", "sql.js"], { + cwd: repo, encoding: "utf8", timeout: 30000, +}); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +function cli(...args: string[]) { + return spawnSync("node", [binary, "otel", ...args], { + env: { ...process.env, HOME: home, USERPROFILE: home, FAILPROOFAI_HOME: join(home, ".failproofai"), + FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_INGEST_KEY: "", FAILPROOFAI_INGEST_URL: "" }, + encoding: "utf8", timeout: 15000, + }); +} + +describe("OTEL standalone bundled CLI", () => { + it("bundles JSONC and TOML without unresolved UMD relative imports", () => { + expect(built.status, built.stderr).toBe(0); + expect(existsSync(binary)).toBe(true); + expect(cli("enable", "--help").status).toBe(0); + }); + + it("enables and restores Codex from a Node-run standalone bundle against an older/unreachable server", () => { + const enabled = cli("enable", "codex", "--local", "--yes"); + expect(enabled.status, enabled.stderr + enabled.stdout).toBe(0); + expect(readFileSync(join(home, ".codex/config.toml"), "utf8")).toContain("/v1/logs"); + const status = cli("status"); + expect(status.status, status.stderr).toBe(0); + expect(status.stdout).toContain("unknown"); + expect(status.stdout).toContain("local relay"); + const disabled = cli("disable", "all"); + expect(disabled.status, disabled.stderr + disabled.stdout).toBe(0); + expect(readFileSync(join(home, ".codex/config.toml"), "utf8")).toBe(original); + }); +}); diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 885eeaadf..6af2e1b9e 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -175,7 +175,7 @@ const headlessIO = () => ({ * churn that tempts someone to "fix" a test by loosening it. Naming the steps * keeps a reorder to a one-line change here. * - * Current order — selectOne: connect, review. + * Current order — selectOne: connect, optional OTEL, review. * multiSelect: assistants. * `undefined` means "this step is not reached in this test". * @@ -190,6 +190,9 @@ function drive(answers: { }) { const one = vi.mocked(selectOne); if ("connect" in answers) one.mockResolvedValueOnce(answers.connect as never); + // OTEL is an intentional new optional step after a successful key choice. + // Keep existing setup scenarios opted out rather than consuming "apply". + if (answers.connect === "key") one.mockResolvedValueOnce(false as never); if ("review" in answers) one.mockResolvedValueOnce(answers.review as never); } diff --git a/__tests__/hooks/daemon-download.test.ts b/__tests__/hooks/daemon-download.test.ts index 33f0a8c2f..c64e140cd 100644 --- a/__tests__/hooks/daemon-download.test.ts +++ b/__tests__/hooks/daemon-download.test.ts @@ -406,7 +406,8 @@ describe("hooks/daemon-download", () => { }); it("ensureFailproofaidBinary prefers the package and never touches the network", async () => { - installPlatformPackage("linux-x64"); + // This entry point resolves the real host, unlike the explicit-key helpers. + installPlatformPackage(`${process.platform}-${process.arch}`); // Any fetch at all fails this test: a machine that already has the // binary from npm must not wait on github.com to install it. const server = await startServer(); @@ -430,7 +431,7 @@ describe("hooks/daemon-download", () => { it("works on an air-gapped machine, where the download channel is switched off", async () => { // FAILPROOFAI_NO_DOWNLOAD gates fetching, not copying — on exactly these // machines npm is the only channel that can supply a daemon at all. - installPlatformPackage("linux-x64"); + installPlatformPackage(`${process.platform}-${process.arch}`); process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; process.env.FAILPROOFAI_DAEMON_BASE_URL = "http://127.0.0.1:1/never"; diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts index e20a2fde1..617749b93 100644 --- a/__tests__/hooks/fp-home.test.ts +++ b/__tests__/hooks/fp-home.test.ts @@ -228,6 +228,7 @@ describe("HOME_CLASSES", () => { fpcliDir: "fpcliDir", packsInstalledFile: "packsDir", packArtifactsDir: "packsDir", + otelAgentStateFile: "otelAgentStateDir", }; /** Every exported function that returns a path inside the home. */ diff --git a/__tests__/hooks/otel.test.ts b/__tests__/hooks/otel.test.ts new file mode 100644 index 000000000..b13c2d468 --- /dev/null +++ b/__tests__/hooks/otel.test.ts @@ -0,0 +1,277 @@ +// @vitest-environment node +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { runOtelCommand, chooseWizardOtel, OTEL_ORG_OFF, OTEL_WIZARD_QUESTION } from "../../src/hooks/otel-cli"; +import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, agentOtelWrites } from "../../src/hooks/otel-writers"; +import { configFile, credentialsFile, otelAgentStateFile, otelAgentStateDir, otlpSpoolDir, resettablePaths } from "../../src/hooks/fp-home"; +import { readConfig, updateConfig } from "../../src/hooks/fp-config"; +import { agentSessionsEnabled } from "../../src/hooks/collector-config"; +import { introspectKey } from "../../src/hooks/cloud-introspect"; + +let home: string; +const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ permissions: ["events:add"] }))); +const originalEnv = { ...process.env }; +const options = () => ({ home, fetchImpl }); +function seed(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-otel-test-")); + process.env.FAILPROOFAI_HOME = join(home, ".failproofai"); + delete process.env.FAILPROOFAI_INGEST_KEY; + delete process.env.FAILPROOFAI_INGEST_URL; + delete process.env.XDG_CONFIG_HOME; + seed(credentialsFile(), JSON.stringify({ ingest: { url: "https://cloud.example/v1/events", key: "capture-test-secret" } })); + seed(configFile(), JSON.stringify({ mode: { kind: "cloud" }, collector: { sessions: false }, future: { keep: true } })); + fetchImpl.mockReset().mockImplementation(async () => new Response(JSON.stringify({ permissions: ["events:add"] }))); +}); +afterEach(() => { + for (const key of ["FAILPROOFAI_HOME", "FAILPROOFAI_INGEST_KEY", "FAILPROOFAI_INGEST_URL", "XDG_CONFIG_HOME"]) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } + rmSync(home, { recursive: true, force: true }); +}); + +describe("managed OTEL settings", () => { + it("migrations never discard restore credentials or undelivered OTLP batches", () => { + expect(resettablePaths()).not.toContain(otelAgentStateDir()); + expect(resettablePaths()).not.toContain(otlpSpoolDir()); + }); + it("round-trips Claude byte-equal, backs up once, preserves unknown keys and keeps keys out of config", async () => { + const path = agentConfigPath("claude", home); + const before = '{\n "env": {"EXISTING": "yes", "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=old-secret"},\n "hooks": {"keep": true}\n}\n'; + seed(path, before); + expect((await runOtelCommand(["enable", "claude"], options())).exitCode).toBe(0); + const enabled = JSON.parse(readFileSync(path, "utf8")); + expect(enabled.env.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1"); + expect(enabled.env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://cloud.example"); + expect(enabled.env.OTEL_LOG_USER_PROMPTS).toBe("1"); + expect(enabled.env.EXISTING).toBe("yes"); + expect(enabled.hooks).toEqual({ keep: true }); + expect(readFileSync(`${path}.failproofai-bak`, "utf8")).toBe(before); + const raw = readFileSync(configFile(), "utf8"); + expect(raw).not.toContain("old-secret"); + expect(raw).not.toContain("capture-test-secret"); + expect(statSync(otelAgentStateFile("claude")).mode & 0o777).toBe(0o600); + expect((await runOtelCommand(["enable", "claude", "--no-content", "--local"], options())).exitCode).toBe(0); + expect(readFileSync(`${path}.failproofai-bak`, "utf8")).toBe(before); + expect((await runOtelCommand(["disable", "claude"], options())).exitCode).toBe(0); + expect(readFileSync(path, "utf8")).toBe(before); + expect(JSON.parse(readFileSync(configFile(), "utf8")).future).toEqual({ keep: true }); + expect(readConfig().collector.otlp?.enabled).toBe(false); + }); + + it("restores only owned keys after unrelated Claude changes", async () => { + const path = agentConfigPath("claude", home); + seed(path, '{"env":{"KEEP":"x","OTEL_LOG_USER_PROMPTS":"old"},"theme":"old"}'); + await runOtelCommand(["enable", "claude"], options()); + const changed = JSON.parse(readFileSync(path, "utf8")); + changed.theme = "new"; + changed.env.NEW_KEY = "keep"; + writeFileSync(path, JSON.stringify(changed)); + await runOtelCommand(["disable", "claude"], options()); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + env: { KEEP: "x", OTEL_LOG_USER_PROMPTS: "old", NEW_KEY: "keep" }, theme: "new", + }); + }); + + it("re-enabling after unrelated edits preserves them on disable and status still recognizes owned keys", async () => { + const path = agentConfigPath("claude", home); + seed(path, '{"theme":"old"}\n'); + await runOtelCommand(["enable", "claude"], options()); + const changed = JSON.parse(readFileSync(path, "utf8")); + changed.theme = "new"; + writeFileSync(path, JSON.stringify(changed)); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Claude Code: on by failproofai"); + await runOtelCommand(["enable", "claude", "--no-content"], options()); + await runOtelCommand(["disable", "claude"], options()); + expect(JSON.parse(readFileSync(path, "utf8")).theme).toBe("new"); + }); + + it("Codex changes only OTEL table regions with comments, nested exporter tables and multiline strings", async () => { + const path = agentConfigPath("codex", home); + const prefix = '# user config\nmodel = "test"\nprompt = """\n[not_a_table]\n"""\n[projects."/work"] # trust\ntrust_level = "trusted"\n\n'; + const otel = '# OTEL\n[otel] # existing\nlog_user_prompt = false\nenvironment = "keep"\n[otel.exporter.otlp-http]\nendpoint = "http://old/v1/logs"\nprotocol = "json"\n'; + const suffix = '# introduces MCP table\n\n[mcp_servers.keep]\n# comment kept\ncommand = "keep"\n'; + const before = prefix + otel + suffix; + seed(path, before); + const result = await runOtelCommand(["enable", "codex"], options()); + expect(result.exitCode, result.lines.join("\n")).toBe(0); + const next = readFileSync(path, "utf8"); + expect(next.startsWith(prefix + "# OTEL\n")).toBe(true); + expect(next.endsWith(suffix)).toBe(true); + const parsed = parseAgentConfig(next, "codex"); + expect(parsed.otel).toMatchObject({ + environment: "keep", + exporter: { "otlp-http": { endpoint: "https://cloud.example/v1/logs", headers: { Authorization: "Bearer capture-test-secret" } } }, + trace_exporter: { "otlp-http": { endpoint: "https://cloud.example/v1/traces" } }, + }); + await runOtelCommand(["disable", "codex"], options()); + expect(readFileSync(path, "utf8")).toBe(before); + }); + + it("Codex supports quoted OTEL headers, CRLF, no final newline; refuses root assignments and malformed files", () => { + const writes = agentOtelWrites("codex", "http://127.0.0.1:4318", "", true, false); + const before = 'model="x"\r\n["otel"]\r\nlog_user_prompt=true\r\n[other]\r\nvalue=1'; + const next = editAgentConfig(before, "codex", writes); + expect(next.startsWith('model="x"\r\n')).toBe(true); + expect(next.endsWith("[other]\r\nvalue=1")).toBe(true); + expect(() => editAgentConfig('otel = {log_user_prompt=true}\n', "codex", writes)).toThrow("root dotted"); + expect(() => editAgentConfig("[broken", "codex", writes)).toThrow(); + }); + + it.each(["gemini", "copilot"] as const)("writes %s with automatic auth-free relay and restores JSONC byte-equal", async agent => { + const path = agentConfigPath(agent, home); + const before = '{\n// user comment\n"editor.fontSize": 13,\n"telemetry": {"unknown": 42},\n}\n'; + seed(path, before); + const result = await runOtelCommand(["enable", agent, "--no-content"], options()); + expect(result.exitCode).toBe(0); + expect(result.lines.join("\n")).toContain("automatically"); + const text = readFileSync(path, "utf8"); + expect(text).toContain("// user comment"); + expect(text).not.toContain("capture-test-secret"); + expect(text).toContain("127.0.0.1:4318"); + const parsed = parseAgentConfig(text, agent); + expect(agent === "gemini" ? (parsed.telemetry as Record).logPrompts : parsed["github.copilot.chat.otel.captureContent"]).toBe(false); + expect(readConfig().collector.otlp?.enabled).toBe(true); + await runOtelCommand(["disable", agent], options()); + expect(readFileSync(path, "utf8")).toBe(before); + }); + + it("skips VS Code when absent and resolves macOS, Linux and Windows user paths", async () => { + expect((await runOtelCommand(["enable", "copilot"], options())).lines.join("\n")).toContain("not installed"); + expect(existsSync(agentConfigPath("copilot", home))).toBe(false); + expect(vscodeSettingsPath(home, "darwin")).toContain("Library/Application Support/Code/User/settings.json"); + expect(vscodeSettingsPath(home, "linux")).toContain(".config/Code/User/settings.json"); + const before = process.env.APPDATA; + process.env.APPDATA = join(home, "Roaming"); + expect(vscodeSettingsPath(home, "win32")).toBe(join(home, "Roaming/Code/User/settings.json")); + if (before === undefined) delete process.env.APPDATA; else process.env.APPDATA = before; + }); + + it("never overwrites an invalid config or creates a backup for it", async () => { + const path = agentConfigPath("claude", home); + seed(path, "{ invalid json"); + expect((await runOtelCommand(["enable", "claude"], options())).exitCode).toBe(1); + expect(readFileSync(path, "utf8")).toBe("{ invalid json"); + expect(existsSync(`${path}.failproofai-bak`)).toBe(false); + }); +}); + +describe("opt-in, one path and status", () => { + it("one-path question defaults No; --yes sets only Claude override and disable restores absence", async () => { + updateConfig({ collector: { sessions: true } }); + const path = agentConfigPath("claude", home); + const question = vi.fn(async () => false); + await runOtelCommand(["enable", "claude"], { ...options(), confirm: question }); + expect(question).toHaveBeenCalledWith("failproofai already sends Claude Code sessions to Cloud. Send them with OTEL instead? Transcript upload for Claude Code stops."); + expect(existsSync(path)).toBe(false); + // Real non-TTY prompt has the conservative default, too. + await runOtelCommand(["enable", "claude"], options()); + expect(existsSync(path)).toBe(false); + await runOtelCommand(["enable", "claude", "--yes"], options()); + expect(agentSessionsEnabled("claude")).toBe(false); + expect(agentSessionsEnabled("codex")).toBe(true); + updateConfig({ telemetry: { enabled: false } }); + expect(agentSessionsEnabled("claude")).toBe(false); + await runOtelCommand(["disable", "claude"], options()); + expect(readConfig().collector.agents?.claude?.sessions).toBeUndefined(); + expect(agentSessionsEnabled("claude")).toBe(true); + expect(existsSync(path)).toBe(false); + }); + + it("Gemini has no transcript source and does not ask a misleading switch question", async () => { + updateConfig({ collector: { sessions: true } }); + const confirm = vi.fn(async () => false); + const result = await runOtelCommand(["enable", "gemini"], { ...options(), confirm }); + expect(result.exitCode).toBe(0); + expect(confirm).not.toHaveBeenCalled(); + expect(readConfig().collector.agents?.gemini).toBeUndefined(); + }); + + it("restores explicit sessions override and keeps relay on until last local agent disables", async () => { + updateConfig({ collector: { agents: { claude: { sessions: true } }, otlp: { enabled: false, port: 14318 } } }); + await runOtelCommand(["enable", "claude", "--yes", "--local"], options()); + await runOtelCommand(["enable", "gemini"], options()); + await runOtelCommand(["disable", "claude"], options()); + expect(readConfig().collector.agents?.claude?.sessions).toBe(true); + expect(readConfig().collector.otlp?.enabled).toBe(true); + await runOtelCommand(["disable", "gemini"], options()); + expect(readConfig().collector.otlp?.enabled).toBe(false); + }); + + it("status shows all agents, Cloud/local, relay, last upload and unknown for old introspect", async () => { + await runOtelCommand(["enable", "claude"], options()); + await runOtelCommand(["enable", "gemini"], options()); + const result = await runOtelCommand(["status"], options()); + const output = result.lines.join("\n"); + for (const agent of ["Claude Code", "Codex", "Gemini CLI", "Copilot"]) expect(output).toContain(agent); + expect(output).toContain("on by failproofai"); + expect(output).toContain("https://cloud.example"); + expect(output).toContain("http://127.0.0.1:4318"); + expect(output).toContain("org: unknown"); + expect(output).toContain("Relay:"); + expect(output).toContain("last upload: none"); + const identity = await introspectKey("https://cloud.example", "key", fetchImpl); + expect(identity.kind === "ok" && identity.identity.otelIngest).toBeUndefined(); + }); + + it("reports unmanaged OTEL and warns but writes when org ingest is off", async () => { + seed(agentConfigPath("codex", home), '[otel]\nexporter = { "otlp-http" = { endpoint="http://external/v1/logs", protocol="json" } }\n'); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Codex: on, not managed"); + fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ permissions: ["events:add"], otel_ingest: false }))); + const result = await runOtelCommand(["enable", "claude"], options()); + expect(result.exitCode).toBe(0); + expect(result.lines).toContain(OTEL_ORG_OFF); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("org: off"); + }); + + it("missing key errors before writes; env is shell-safe and has the correct protocol/header", async () => { + const result = await runOtelCommand(["env", "--service", "service's name"], options()); + expect(result.lines.join("\n")).toContain("http/protobuf"); + expect(result.lines.join("\n")).toContain("Authorization=Bearer%20capture-test-secret"); + expect(result.lines.join("\n")).toContain("'service'\\''s name'"); + expect((await runOtelCommand(["env", "--local"], options())).lines.join("\n")).not.toContain("capture-test-secret"); + rmSync(credentialsFile()); + const error = await runOtelCommand(["enable", "claude"], options()); + expect(error.exitCode).toBe(1); + expect(error.lines.join("\n")).toContain("failproofai config"); + expect(existsSync(agentConfigPath("claude", home))).toBe(false); + }); + + it("wizard question defaults No and uses the shared enable entry point", async () => { + expect(await chooseWizardOtel()).toBe(false); + const choose = vi.fn(async () => true); + expect(await chooseWizardOtel(choose)).toBe(true); + expect(choose).toHaveBeenCalledWith(OTEL_WIZARD_QUESTION); + const source = readFileSync(join(__dirname, "../../src/hooks/configure-wizard.ts"), "utf8"); + expect(source).toContain('runOtelCommand(["enable", agent]'); + expect(source).toContain("!unattended && !preAnswered"); + }); + + it("analytics properties contain only agent, local and content", async () => { + const onEvent = vi.fn(); + await runOtelCommand(["enable", "claude", "--local", "--no-content"], { ...options(), onEvent }); + await runOtelCommand(["disable", "claude"], { ...options(), onEvent }); + expect(onEvent.mock.calls).toEqual([ + ["otel_enabled", { agent: "claude", local: true, content: false }], + ["otel_disabled", { agent: "claude", local: true, content: false }], + ]); + }); + + it.each([{ args: [] }, { args: ["enable"] }, { args: ["disable"] }, { args: ["status"] }, { args: ["env"] }])("real CLI help $args follows harness section style", ({ args }) => { + const result = spawnSync("bun", ["bin/failproofai.mjs", "otel", ...args, "--help"], { + cwd: join(__dirname, "../.."), env: { ...process.env, HOME: home, FAILPROOFAI_TELEMETRY_DISABLED: "1" }, + encoding: "utf8", timeout: 15000, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("otel"); + expect(result.stdout.toLowerCase()).toContain("usage"); + expect(result.stdout.toLowerCase()).toContain("notes"); + }); +}); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 9367cd3b9..751fefd99 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -373,7 +373,7 @@ async function runCli() { // at all, only the paragraph in the top-level dump that this rewrite moved. // `help` and `publish` are new. `policy` and `pack` are canonicalized to // `policies` above and never reach this list. - const SUBCOMMANDS = ["policies", "audit", "config", "uninstall", "backfill", "flush", "harness", "jev", "publish", "update", "migrate", "help"]; + const SUBCOMMANDS = ["policies", "audit", "config", "uninstall", "backfill", "flush", "harness", "otel", "jev", "publish", "update", "migrate", "help"]; // ── help ───────────────────────────────────────────────────────────────── // // The index and the reference manual used to be the same document: 152 lines, @@ -491,7 +491,7 @@ async function runCli() { // and a full row each is what made this screen read as a manual. { label: "less often", - lines: ["policies show, harness, flush, backfill, migrate, uninstall, config --pause"], + lines: ["policies show, harness, otel, flush, backfill, migrate, uninstall", "config --pause"], }, ], footer: [ @@ -808,6 +808,48 @@ async function runCli() { // could reach it to notice. The pack lane is entered from // `policies add|remove|show` below, which is the only door it has. + if (args[0] === "otel") { + const subArgs = args.slice(1); + if (!subArgs.length || subArgs.includes("--help") || subArgs.includes("-h")) { + const verb = ["status", "enable", "disable", "env"].includes(subArgs[0]) ? subArgs[0] : null; + const usages = { + status: "failproofai otel status", + enable: "failproofai otel enable [--local] [--no-content] [--yes]", + disable: "failproofai otel disable ", + env: "failproofai otel env [--service ] [--local]", + }; + await printHelp({ + command: verb ? `otel ${verb}` : "otel", + tagline: "opt-in OpenTelemetry from agents and apps to FailproofAI Cloud", + sections: [ + { label: "usage", entries: (verb ? [usages[verb]] : Object.values(usages)).map(usage => [usage]) }, + { label: "agents", lines: ["claude, codex, gemini, copilot; all configures every available agent."] }, + { label: "options", entries: [ + ["--local", "Use the loopback OTLP relay. Automatic for Gemini CLI and Copilot."], + ["--no-content", "Do not capture prompt text or tool details."], + ["--yes", "Accept switching this agent from transcript upload to OTEL."], + ["--service ", "Service name for env (default: my-agent)."], + ] }, + { label: "notes", lines: [ + "Off by default. Run `failproofai config` first to configure your Cloud key.", + "Text capture is on unless --no-content. Disable restores only managed settings.", + "An org admin must enable Settings → OpenTelemetry in Cloud.", + ] }, + ], + }); + process.exit(0); + } + lastSubcommand = "otel"; + const { runOtelCommand } = await import("../src/hooks/otel-cli"); + const result = await runOtelCommand(subArgs, { onEvent: track }); + // env is shell input and must not be wrapped in a styled report. + if (subArgs[0] === "env" && result.exitCode === 0) console.log(result.lines.join("\n")); + else await printReport("otel", result.lines, { ok: result.exitCode === 0 }); + lastSubcommand = null; + await exitAfterFlush(result.exitCode); + return; + } + if (args[0] === "harness") { const subArgs = args.slice(1); if (subArgs.length === 0 || subArgs.includes("--help") || subArgs.includes("-h")) { diff --git a/bun.lock b/bun.lock index af3ba23e6..1a88d0312 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,9 @@ "name": "failproofai", "dependencies": { "html-to-image": "^1.11.13", + "jsonc-parser": "^3.3.1", "posthog-node": "^5.50.0", + "smol-toml": "^1.9.1", "sql.js": "^1.14.1", "yaml": "^2.9.0", }, @@ -832,6 +834,8 @@ "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], @@ -1116,6 +1120,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/package.json b/package.json index 353052314..32d8fa621 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,9 @@ }, "dependencies": { "html-to-image": "^1.11.13", + "jsonc-parser": "^3.3.1", "posthog-node": "^5.50.0", + "smol-toml": "^1.9.1", "sql.js": "^1.14.1", "yaml": "^2.9.0" }, diff --git a/src/hooks/cloud-introspect.ts b/src/hooks/cloud-introspect.ts index 84cfc996d..39a673824 100644 --- a/src/hooks/cloud-introspect.ts +++ b/src/hooks/cloud-introspect.ts @@ -44,6 +44,8 @@ export interface KeyIdentity { /** The EFFECTIVE permission set — what the server enforces. */ permissions: string[]; expiresAt?: string | null; + /** Missing on older Cloud versions; absence is unknown, never an error. */ + otelIngest?: boolean; } export type IntrospectResult = @@ -128,6 +130,7 @@ export async function introspectKey( orgName: typeof b.org_name === "string" ? b.org_name : undefined, permissions: b.permissions.filter((p): p is string => typeof p === "string"), expiresAt: typeof b.expires_at === "string" ? b.expires_at : null, + otelIngest: typeof b.otel_ingest === "boolean" ? b.otel_ingest : undefined, }, }; } diff --git a/src/hooks/collector-config.ts b/src/hooks/collector-config.ts index 5828ddf09..ee5091970 100644 --- a/src/hooks/collector-config.ts +++ b/src/hooks/collector-config.ts @@ -13,7 +13,7 @@ * the only thing that writes them. */ import { credentialsFile, failproofaiHome as layoutHome } from "./fp-home"; -import { readCredentials, writeCredentials, updateConfig } from "./fp-config"; +import { readConfig, readCredentials, writeCredentials, updateConfig } from "./fp-config"; /** * The hosted ingest endpoint — a COMPLETE endpoint, not a base to join onto. @@ -42,6 +42,8 @@ export interface IngestCredential { export interface CollectorSettings { /** Ship agent session transcripts. Separate opt-in from having a key. */ sessions: boolean; + agents?: Record; + otlp?: { enabled: boolean; port?: number }; /** Ship hook activity. */ hooks: boolean; hooksVerbosity?: "all" | "decisions" | "off"; @@ -60,6 +62,11 @@ export function failproofaiHome(): string { return layoutHome(); } +export function agentSessionsEnabled(agent: string): boolean { + const collector = readConfig().collector; + return collector.agents?.[agent]?.sessions ?? collector.sessions; +} + export function ingestPath(): string { return credentialsFile(); } @@ -122,6 +129,8 @@ export function writeCollectorSettings(settings: CollectorSettings): void { redact: settings.redact ?? "minimal", environment: settings.environment ?? "local", machineId: settings.machineId, + agents: settings.agents ?? readConfig().collector.agents, + otlp: settings.otlp ?? readConfig().collector.otlp, }, }); } diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index 2d1c4fd44..9269a1708 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -34,6 +34,7 @@ import { CORE_SOURCE } from "./pack-store"; import { selectOne, + multiSelect, promptText, intro, outro, @@ -1271,6 +1272,18 @@ export async function runConfigureWizard( } } } + // Optional OTEL choice is never implied by unattended setup or a pasted key. + const { chooseWizardOtel, runOtelCommand } = await import("./otel-cli"); + const otelWanted = !unattended && !preAnswered && connect !== null && await chooseWizardOtel( + async message => await selectOne({ + message, + choices: [ + { label: "No", value: false, hint: "keep current telemetry" }, + { label: "Yes", value: true, hint: "choose OTEL agents after connecting" }, + ], + stdin, stdout, + }) === true, + ); // 5 — Review & apply // // The last question, and the only one a headless run skips outright rather @@ -1557,6 +1570,32 @@ export async function runConfigureWizard( } } + if (otelWanted && connected) { + const selected = await multiSelect({ + message: "Which agents should send OpenTelemetry?", + choices: [ + { label: "Gemini CLI", value: "gemini", checked: false }, + { label: "Copilot (VS Code)", value: "copilot", checked: false }, + { label: "Claude Code", value: "claude", checked: false }, + { label: "Codex", value: "codex", checked: false }, + ], + stdin, stdout, + }); + if (Array.isArray(selected)) for (const agent of selected) { + const result = await runOtelCommand(["enable", agent], { + onEvent: emit, + confirm: async message => await selectOne({ + message, + choices: [ + { label: "No", value: false, hint: "keep transcript upload" }, + { label: "Yes", value: true, hint: "switch to OTEL" }, + ], + stdin, stdout, + }) === true, + }); + for (const line of result.lines) stdout.write(`${line}\n`); + } + } await applied; // Only now — a completed apply — is the launcher considered "seen", so // first-run onboarding stops offering itself on every command. diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts index d9ffc9e5a..2c866c528 100644 --- a/src/hooks/fp-config.ts +++ b/src/hooks/fp-config.ts @@ -281,6 +281,8 @@ export interface FpConfig { }; collector: { sessions: boolean; + agents?: Record; + otlp?: { enabled: boolean; port?: number }; hooks: boolean; hooksVerbosity: "all" | "decisions" | "off"; redact: "minimal" | "off"; @@ -443,6 +445,25 @@ function readSources(raw: unknown): FpConfig["collector"]["sources"] { return Object.keys(out).length > 0 ? out : undefined; } +function readAgentSessions(raw: unknown): FpConfig["collector"]["agents"] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const agents = Object.fromEntries(Object.entries(raw).flatMap(([agent, settings]) => { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) return []; + const sessions = (settings as Record).sessions; + return typeof sessions === "boolean" ? [[agent, { sessions }]] : []; + })); + return Object.keys(agents).length ? agents : undefined; +} + +function readOtlpSettings(raw: unknown): FpConfig["collector"]["otlp"] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const settings = raw as Record; + return { + enabled: settings.enabled === true, + ...(typeof settings.port === "number" && Number.isInteger(settings.port) ? { port: settings.port } : {}), + }; +} + /** * {@link readConfig}, plus the raw object it was projected from. * @@ -532,6 +553,8 @@ export function projectConfig(parsed: Record): FpConfig { typeof collector.environment === "string" ? collector.environment : "local", machineId: typeof collector.machine_id === "string" ? collector.machine_id : undefined, sources: readSources(collector.sources), + agents: readAgentSessions(collector.agents), + otlp: readOtlpSettings(collector.otlp), }, // Only an explicit `false` switches it off. Absent, or any other value, // reads as on — the shipped default, and what a config with no @@ -594,6 +617,9 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [ ["collector", "environment"], ["collector", "machine_id"], ["collector", "sources", "*", "extra_paths"], + ["collector", "agents", "*", "sessions"], + ["collector", "otlp", "enabled"], + ["collector", "otlp", "port"], ["telemetry", "enabled"], ["audit", "auto"], ["audit", "interval_days"], @@ -669,6 +695,8 @@ export function writeConfig(config: FpConfig, raw?: Record): vo // Omitted when unset rather than written null: the Rust side treats an // absent machine_id as "derive one", and an explicit null is not that. ...(c.machineId ? { machine_id: c.machineId } : {}), + ...(c.agents ? { agents: c.agents } : {}), + ...(c.otlp ? { otlp: c.otlp } : {}), // Extra capture paths per harness. Emitted ONLY when non-empty, so a // machine that never configured one gets a file byte-identical to what // this function produced before the field existed — the same reasoning as diff --git a/src/hooks/fp-home.ts b/src/hooks/fp-home.ts index 395bfe2ae..344ca93aa 100644 --- a/src/hooks/fp-home.ts +++ b/src/hooks/fp-home.ts @@ -139,6 +139,14 @@ export const versionFile = (home?: string) => atHome(home, "VERSION"); /** Non-secret configuration. World-readable by design; never holds a token. */ export const configFile = (home?: string) => atHome(home, "config.json"); +export const otelAgentStateDir = (home?: string) => atHome(home, "state", "otel-agents"); +export const otelAgentStateFile = (agent: string) => { + if (!["claude", "codex", "gemini", "copilot"].includes(agent)) throw new Error("Unknown OTEL agent."); + return resolve(otelAgentStateDir(), `${agent}.json`); +}; +export const otlpSpoolDir = (home?: string) => atHome(home, "state", "spool-otlp"); +export const otlpHealthFile = (home?: string) => atHome(home, "state", "otlp-health.json"); + /** * Every credential, owner-only. * @@ -576,6 +584,7 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // the collector preferences, `[audit] auto`, the telemetry opt-out, and // `collector.sources.*.extra_paths` — the entire output of `harness add-path`. { path: configFile, class: "user-typed" }, + { path: otelAgentStateDir, class: "user-typed" }, // The builtin enable/disable set and per-policy params. `resetHome` used to // clear this and carry eight named keys back over; it is simply kept now. { path: globalPolicyConfigFile, class: "user-typed" }, @@ -602,6 +611,7 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // these is PERMANENT and not merely slow is `cursorsDir` below: the watermark // has already advanced past them, so nothing will ever read that range again. { path: spoolDir, class: "undelivered" }, + { path: otlpSpoolDir, class: "undelivered" }, { path: failedDir, class: "undelivered" }, // The SDK spool (`events/` + `failed/`), same argument. { path: customAgentsDir, class: "undelivered" }, @@ -657,6 +667,7 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da { path: auditCacheDir, class: "derived" }, { path: auditScheduleFile, class: "derived" }, { path: collectorHealthFile, class: "derived" }, + { path: otlpHealthFile, class: "derived" }, { path: codexSessionPathsFile, class: "derived" }, { path: shimsDir, class: "derived" }, { path: sessionPauseDir, class: "derived" }, diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index bb1bf5e68..3d1c2ae76 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -85,7 +85,7 @@ export class UnreadableAgentConfigError extends Error { } } -function readJsonFile(path: string): Record { +export function readJsonFile(path: string): Record { if (!existsSync(path)) return {}; let raw: string; try { diff --git a/src/hooks/otel-cli.ts b/src/hooks/otel-cli.ts new file mode 100644 index 000000000..38441d100 --- /dev/null +++ b/src/hooks/otel-cli.ts @@ -0,0 +1,340 @@ +/** Opt-in agent setup; no OTLP translation or changes to the events uploader. */ +import { + chmodSync, existsSync, mkdirSync, readFileSync, + statSync, unlinkSync, writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { connect } from "node:net"; +import { readConfig, readCredentials } from "./fp-config"; +import { configFile, otelAgentStateFile, otlpHealthFile } from "./fp-home"; +import { writeConfigFileAtomic } from "./safe-config-write"; +import { agentSessionsEnabled } from "./collector-config"; +import { introspectKey } from "./cloud-introspect"; +import { readJsonFile } from "./integrations"; +import { selectOne } from "./tui"; +import { + AGENT_LABELS, OTEL_AGENTS, agentConfigPath, agentOtelWrites, editAgentConfig, + getSetting, parseAgentConfig, vscodeInstalled, + type JsonObject, type OtelAgent, type SettingWrite, +} from "./otel-writers"; + +export const OTEL_ORG_OFF = "OTEL ingest is off for your org. An admin can turn it on in Settings → OpenTelemetry."; +export const OTEL_WIZARD_QUESTION = "Also send OpenTelemetry from agents? (Gemini CLI, Copilot, or switch Claude Code / Codex)"; +export interface OtelResult { + lines: string[]; + exitCode: number; + events?: { name: "otel_enabled" | "otel_disabled"; agent: OtelAgent; local: boolean; content: boolean }[]; +} +interface AgentRecord { + file: string; + local: boolean; + content: boolean; + destination: string; + keys: { path: string[]; hadPrevious: boolean; previous?: unknown; private?: boolean }[]; + previousSessions?: boolean; + switchedSessions: boolean; +} +interface PrivateState { + original: string | null; + originalMode?: number; + writtenHash: string; + previous: SettingWrite[]; +} +export interface OtelOptions { + home?: string; + fetchImpl?: typeof fetch; + confirm?: (question: string) => Promise; + onEvent?: (name: string, properties: { agent: OtelAgent; local: boolean; content: boolean }) => void | Promise; +} + +function rawConfig(): JsonObject { + return readJsonFile(configFile()); +} +function object(value: unknown): JsonObject { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid OTEL configuration object."); + return value as JsonObject; +} +function records(raw: JsonObject): Partial> { + return object(object(raw.otel).agents) as Partial>; +} +function saveConfig(raw: JsonObject): void { + writeConfigFileAtomic(configFile(), `${JSON.stringify(raw, null, 2)}\n`, { backup: false }); +} +function digest(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} +function privateState(agent: OtelAgent): PrivateState { + return JSON.parse(readFileSync(otelAgentStateFile(agent), "utf8")) as PrivateState; +} +function credential(): { key: string; origin: string } { + const ingest = readCredentials().ingest; + const key = process.env.FAILPROOFAI_INGEST_KEY || ingest?.key; + if (!key) throw new Error("No Cloud API key configured. Run `failproofai config` first."); + const url = new URL(process.env.FAILPROOFAI_INGEST_URL || ingest?.url || "https://app.befailproof.ai"); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid Cloud ingest URL."); + return { key, origin: url.origin }; +} +function backupOnce(path: string, original: string | null): void { + if (existsSync(`${path}.failproofai-bak`)) return; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(`${path}.failproofai-bak`, original ?? "", { flag: "wx", mode: 0o600 }); + chmodSync(`${path}.failproofai-bak`, 0o600); +} +function savePrivate(agent: OtelAgent, state: PrivateState): void { + const path = otelAgentStateFile(agent); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeConfigFileAtomic(path, JSON.stringify(state), { backup: false, mode: 0o600 }); + chmodSync(path, 0o600); +} +function configureRelay(raw: JsonObject): void { + const collector = object(raw.collector); + raw.collector = collector; + const local = Object.values(records(raw)).some(record => record?.local); + if (local) collector.otlp = { ...object(collector.otlp), enabled: true }; + else if (collector.otlp) collector.otlp = { ...object(collector.otlp), enabled: false }; +} +function hasExternalOtel(agent: OtelAgent, config: JsonObject): boolean { + if (agent === "claude") return getSetting(config, ["env", "CLAUDE_CODE_ENABLE_TELEMETRY"]) === "1"; + if (agent === "codex") { + return ["exporter", "trace_exporter", "metrics_exporter"].some(k => { + const value = getSetting(config, ["otel", k]); + return value !== undefined && value !== "none" && value !== "statsig"; + }); + } + return getSetting(config, agent === "gemini" ? ["telemetry", "enabled"] : ["github.copilot.chat.otel.enabled"]) === true; +} +async function defaultConfirm(question: string): Promise { + return await selectOne({ + message: question, + choices: [ + { label: "No", value: false, hint: "keep transcript upload" }, + { label: "Yes", value: true, hint: "switch this agent to OTEL" }, + ], + }) === true; +} +export async function chooseWizardOtel( + choose: (question: string) => Promise = defaultConfirm, +): Promise { + return choose(OTEL_WIZARD_QUESTION); +} +function restoreAgent(agent: OtelAgent, raw: JsonObject): void { + const all = records(raw); + const record = all[agent]; + if (!record) return; + const state = privateState(agent); + if (existsSync(record.file)) { + const current = readFileSync(record.file, "utf8"); + if (digest(current) === state.writtenHash) { + if (state.original === null) unlinkSync(record.file); + else writeConfigFileAtomic(record.file, state.original, { backup: false, mode: state.originalMode }); + } else { + const restored = editAgentConfig(current, agent, state.previous); + writeConfigFileAtomic(record.file, restored, { backup: false, mode: state.originalMode }); + } + } + if (record.switchedSessions) { + const collector = object(raw.collector); + const agents = object(collector.agents); + const settings = object(agents[agent]); + if (record.previousSessions === undefined) delete settings.sessions; + else settings.sessions = record.previousSessions; + if (Object.keys(settings).length) agents[agent] = settings; + else delete agents[agent]; + if (Object.keys(agents).length) collector.agents = agents; + else delete collector.agents; + } + delete all[agent]; + object(raw.otel).agents = all; +} +async function enableAgent(agent: OtelAgent, yes: boolean, localFlag: boolean, content: boolean, options: OtelOptions): Promise { + const { key, origin } = credential(); + const raw = rawConfig(); + const previousConfig = structuredClone(raw); + const file = agentConfigPath(agent, options.home ?? homedir()); + if (agent === "copilot" && !vscodeInstalled(file)) { + return { lines: ["VS Code is not installed (no user settings directory); skipping Copilot."], exitCode: 0 }; + } + const existing = records(raw)[agent]; + // Gemini has no transcript source in this collector. + const transcriptsEnabled = agent !== "gemini" && agentSessionsEnabled(agent); + if (!existing && transcriptsEnabled && !yes) { + const question = `failproofai already sends ${AGENT_LABELS[agent]} sessions to Cloud. Send them with OTEL instead? Transcript upload for ${AGENT_LABELS[agent]} stops.`; + if (!await (options.confirm ?? defaultConfirm)(question)) return { lines: [`${AGENT_LABELS[agent]} unchanged (default No).`], exitCode: 0 }; + } + const local = localFlag || agent === "gemini" || agent === "copilot"; + const port = readConfig().collector.otlp?.port ?? 4318; + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("collector.otlp.port must be between 1 and 65535."); + const destination = local ? `http://127.0.0.1:${port}` : origin; + const original = existsSync(file) ? readFileSync(file, "utf8") : null; + const parsed = parseAgentConfig(original ?? "", agent); + const writes = agentOtelWrites(agent, destination, key, local, content); + const next = editAgentConfig(original ?? "", agent, writes); + // On repeated enable keep the ORIGINAL previous values, not our last write. + const state = existing ? privateState(agent) : { + original, + originalMode: original === null ? undefined : statSync(file).mode & 0o777, + writtenHash: "", + previous: writes.map(w => ({ path: w.path, value: getSetting(parsed, w.path) })), + }; + if (existing && original !== null && digest(original) !== state.writtenHash) { + // Re-enabling must not turn the fast byte-restore path into permission to + // discard changes made after the previous enable. + state.original = editAgentConfig(original, agent, state.previous); + } + const collector = object(raw.collector); + const agents = object(collector.agents); + const previousSessions = object(agents[agent]).sessions as boolean | undefined; + const record: AgentRecord = { + file, local, content, destination, + keys: state.previous.map(w => ({ + path: w.path, + hadPrevious: w.value !== undefined, + // Prior agent credentials belong in owner-only state, never config.json. + ...(w.path.some(k => /headers|exporter/i.test(k)) ? { private: true } : { previous: w.value }), + })), + previousSessions: existing ? existing.previousSessions : previousSessions, + switchedSessions: existing ? existing.switchedSessions : transcriptsEnabled, + }; + if (record.switchedSessions) { + agents[agent] = { ...object(agents[agent]), sessions: false }; + collector.agents = agents; + } + raw.collector = collector; + const otel = object(raw.otel); + otel.agents = { ...records(raw), [agent]: record }; + raw.otel = otel; + configureRelay(raw); + // Recovery state and one-time backup precede the first agent-file write. + backupOnce(file, original); + savePrivate(agent, { ...state, writtenHash: digest(next) }); + try { + // Publish ownership BEFORE the agent settings so a killed CLI can always + // disable/restore what it wrote, including the one-path transcript choice. + saveConfig(raw); + writeConfigFileAtomic(file, next, { backup: false, mode: 0o600 }); + // Agent files now contain a credential, even if previously world-readable. + chmodSync(file, 0o600); + } catch (err) { + if (original !== null) writeConfigFileAtomic(file, original, { backup: false, mode: state.originalMode }); + else if (existsSync(file)) unlinkSync(file); + saveConfig(previousConfig); + throw err; + } + const lines = [`${AGENT_LABELS[agent]} OTEL enabled → ${local ? "local relay" : "Cloud"} (${content ? "text capture on" : "no content"}).`]; + if (local && !localFlag) lines.push(`${AGENT_LABELS[agent]} settings cannot carry an auth header; using the local relay automatically.`); + if (local) lines.push("The daemon picks this up within seconds — no restart. Use `failproofai otel status` to check the relay."); + const result = await introspectKey(origin, key, options.fetchImpl, 3000); + if (result.kind === "ok" && result.identity.otelIngest === false) lines.push(OTEL_ORG_OFF); + await options.onEvent?.("otel_enabled", { agent, local, content }); + return { lines, exitCode: 0, events: [{ name: "otel_enabled", agent, local, content }] }; +} +function relayListening(port: number): Promise { + return new Promise(resolve => { + const socket = connect({ host: "127.0.0.1", port }); + const done = (ok: boolean) => { socket.destroy(); resolve(ok); }; + socket.setTimeout(200); + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.once("timeout", () => done(false)); + }); +} +async function status(options: OtelOptions): Promise { + let org = "unknown"; + try { + const { origin, key } = credential(); + const result = await introspectKey(origin, key, options.fetchImpl, 3000); + if (result.kind === "ok" && result.identity.otelIngest !== undefined) org = result.identity.otelIngest ? "on" : "off"; + } catch { /* Status works on an unconfigured machine too. */ } + const all = records(rawConfig()); + const lines = [`Org OTEL ingest: ${org}`]; + for (const agent of OTEL_AGENTS) { + const record = all[agent]; + let parsed: JsonObject = {}; + let unreadable = false; + try { + const file = record?.file ?? agentConfigPath(agent, options.home ?? homedir()); + if (existsSync(file)) parsed = parseAgentConfig(readFileSync(file, "utf8"), agent); + } catch { unreadable = true; } + const on = hasExternalOtel(agent, parsed); + let managed = false; + if (record && on) { + try { + const state = privateState(agent); + const expected = parseAgentConfig(editAgentConfig(state.original ?? "", agent, + agentOtelWrites(agent, record.destination, credential().key, record.local, record.content)), agent); + managed = record.keys.every(k => JSON.stringify(getSetting(parsed, k.path)) === JSON.stringify(getSetting(expected, k.path))); + } catch { /* Missing recovery state or key. */ } + } + const dest = record?.destination ?? getSetting(parsed, agent === "claude" ? ["env", "OTEL_EXPORTER_OTLP_ENDPOINT"] : + agent === "gemini" ? ["telemetry", "otlpEndpoint"] : + agent === "copilot" ? ["github.copilot.chat.otel.otlpEndpoint"] : ["otel", "exporter", "otlp-http", "endpoint"]); + const destination = dest ? `${record?.local || String(dest).startsWith("http://127.0.0.1:") ? "local relay" : "Cloud"} (${dest})` : "none"; + lines.push(`${AGENT_LABELS[agent]}: ${unreadable ? "unreadable settings" : on ? managed ? "on by failproofai" : "on, not managed" : "off"} · destination: ${destination} · org: ${org}`); + } + let health: JsonObject = {}; + try { health = JSON.parse(readFileSync(otlpHealthFile(), "utf8")) as JsonObject; } catch { /* No relay yet. */ } + const requested = readConfig().collector.otlp?.enabled === true; + const live = requested && health.relay === "listening" && await relayListening(readConfig().collector.otlp?.port ?? 4318); + lines.push(`Relay: ${live ? "listening on 127.0.0.1" : requested ? health.relay === "port_busy" ? "off (port busy)" : "off (daemon not listening)" : "off"} · last upload: ${health.last_upload ?? "none"}`); + return { lines, exitCode: 0 }; +} + +export async function runOtelCommand(args: string[], options: OtelOptions = {}): Promise { + try { + const [verb, ...rest] = args; + const flags = new Set(verb === "enable" ? ["--local", "--no-content", "--yes"] : verb === "env" ? ["--local", "--service"] : []); + const unexpected = rest.find((arg, i) => arg.startsWith("-") && !flags.has(arg) && rest[i - 1] !== "--service"); + if (unexpected) throw new Error(`Unexpected argument: ${unexpected}`); + if (verb === "status") { + if (rest.length) throw new Error("status takes no arguments."); + return await status(options); + } + if (verb === "env") { + const { origin, key } = credential(); + const index = rest.indexOf("--service"); + const service = index >= 0 ? rest[index + 1] : "my-agent"; + if (!service || service.startsWith("-")) throw new Error("Missing value after --service."); + const consumed = rest.filter((_, i) => i !== index && i !== index + 1 || index < 0).filter(x => x !== "--local"); + if (consumed.length) throw new Error("env takes only --service and --local."); + const local = rest.includes("--local"); + const endpoint = local ? `http://127.0.0.1:${readConfig().collector.otlp?.port ?? 4318}` : origin; + const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; + const values = { + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_HEADERS: local ? "" : `Authorization=Bearer%20${encodeURIComponent(key)}`, + OTEL_SERVICE_NAME: service, + }; + return { lines: Object.entries(values).map(([k, v]) => `export ${k}=${quote(v)}`), exitCode: 0 }; + } + if (verb !== "enable" && verb !== "disable") throw new Error("Expected status, enable, disable or env. Run `failproofai otel --help`."); + const target = rest[0]; + if (!target || (target !== "all" && !OTEL_AGENTS.includes(target as OtelAgent))) throw new Error(`Choose an agent: ${OTEL_AGENTS.join(", ")}, all.`); + if (rest.slice(1).some(x => !flags.has(x))) throw new Error("Unexpected positional argument."); + const agents = target === "all" ? [...OTEL_AGENTS] : [target as OtelAgent]; + const result: OtelResult = { lines: [], exitCode: 0, events: [] }; + for (const agent of agents) { + if (verb === "enable") { + const enabled = await enableAgent(agent, rest.includes("--yes"), rest.includes("--local"), !rest.includes("--no-content"), options); + result.lines.push(...enabled.lines); + result.events?.push(...enabled.events ?? []); + } else { + const raw = rawConfig(); + const record = records(raw)[agent]; + if (!record) { result.lines.push(`${AGENT_LABELS[agent]}: no managed OTEL settings.`); continue; } + restoreAgent(agent, raw); + configureRelay(raw); + saveConfig(raw); + unlinkSync(otelAgentStateFile(agent)); + result.lines.push(`${AGENT_LABELS[agent]} OTEL disabled; previous settings restored.`); + await options.onEvent?.("otel_disabled", { agent, local: record.local, content: record.content }); + result.events?.push({ name: "otel_disabled", agent, local: record.local, content: record.content }); + } + } + return result; + } catch (err) { + return { lines: [err instanceof Error ? err.message : String(err)], exitCode: 1 }; + } +} diff --git a/src/hooks/otel-writers.ts b/src/hooks/otel-writers.ts new file mode 100644 index 000000000..ed872b759 --- /dev/null +++ b/src/hooks/otel-writers.ts @@ -0,0 +1,187 @@ +/** + * Agent-owned OTEL configuration. JSONC edits preserve comments; the TOML + * scanner identifies tables without mistaking a multiline string for one. + * smol-toml validates the complete document before any modification. + */ +import { parse as parseToml } from "smol-toml"; +import { applyEdits, modify, parse as parseJsonc, type ParseError } from "jsonc-parser/lib/esm/main.js"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { existsSync } from "node:fs"; + +export const OTEL_AGENTS = ["claude", "codex", "gemini", "copilot"] as const; +export type OtelAgent = typeof OTEL_AGENTS[number]; +export const AGENT_LABELS: Record = { + claude: "Claude Code", codex: "Codex", gemini: "Gemini CLI", copilot: "Copilot", +}; +export type JsonObject = Record; +export interface SettingWrite { path: string[]; value?: unknown } + +export function vscodeSettingsPath(home = homedir(), platform = process.platform): string { + if (platform === "darwin") return resolve(home, "Library/Application Support/Code/User/settings.json"); + if (platform === "win32") return resolve(process.env.APPDATA ?? resolve(home, "AppData/Roaming"), "Code/User/settings.json"); + return resolve(process.env.XDG_CONFIG_HOME ?? resolve(home, ".config"), "Code/User/settings.json"); +} + +export function agentConfigPath(agent: OtelAgent, home = homedir()): string { + return agent === "copilot" ? vscodeSettingsPath(home) : + resolve(home, `.${agent}`, agent === "codex" ? "config.toml" : "settings.json"); +} + +export function vscodeInstalled(path: string): boolean { + return existsSync(resolve(path, "..")); +} + +export function parseAgentConfig(text: string, agent: OtelAgent): JsonObject { + if (agent === "codex") return parseToml(text) as JsonObject; + if (!text.trim()) return {}; + const errors: ParseError[] = []; + const value: unknown = parseJsonc(text, errors, { allowTrailingComma: true }); + if (errors.length || !value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Refusing to modify an invalid agent settings object."); + } + return value as JsonObject; +} + +export function getSetting(object: JsonObject, path: string[]): unknown { + let value: unknown = object; + for (const key of path) { + if (!value || typeof value !== "object") return undefined; + value = (value as JsonObject)[key]; + } + return value; +} + +function setSetting(object: JsonObject, path: string[], value: unknown): void { + const [key, ...rest] = path; + if (!rest.length) { + if (value === undefined) delete object[key]; + else object[key] = value; + return; + } + const child = object[key]; + if (child !== undefined && (!child || typeof child !== "object" || Array.isArray(child))) { + throw new Error(`Refusing to replace a non-object ${key} setting.`); + } + const nested = (child ?? {}) as JsonObject; + setSetting(nested, rest, value); + if (Object.keys(nested).length) object[key] = nested; + else delete object[key]; +} + +function tomlValue(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "boolean" || typeof value === "number") return String(value); + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return `[${value.map(tomlValue).join(", ")}]`; + if (value && typeof value === "object") return `{ ${Object.entries(value).map(([k, v]) => `${JSON.stringify(k)} = ${tomlValue(v)}`).join(", ")} }`; + throw new Error("Unsupported TOML value."); +} + +function tables(text: string): { start: number; otel: boolean }[] { + const result: { start: number; otel: boolean }[] = []; + let multiline: string | null = null; + let offset = 0; + for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) { + if (!multiline) { + const header = /^\s*(\[\[?.*?\]\]?)\s*(?:#.*)?(?:\r?\n)?$/.exec(line); + if (header) { + const sample = parseToml(`${header[1]}\n__failproofai_probe = true\n`); + result.push({ start: offset, otel: Object.hasOwn(sample, "otel") }); + } + } + let quote: string | null = null; + for (let i = 0; i < line.length; i++) { + if (multiline) { + if (line.startsWith(multiline, i) && (multiline === "'''" || line[i - 1] !== "\\")) { + i += 2; multiline = null; + } + } else if (quote) { + if (quote === '"' && line[i] === "\\") i++; + else if (line[i] === quote) quote = null; + } else if (line[i] === "#") break; + else if (line.startsWith('"""', i) || line.startsWith("'''", i)) { + multiline = line.slice(i, i + 3); i += 2; + } else if (line[i] === '"' || line[i] === "'") quote = line[i]; + } + offset += line.length; + } + return result; +} + +export function editAgentConfig(text: string, agent: OtelAgent, writes: SettingWrite[]): string { + const parsed = parseAgentConfig(text, agent); + if (agent !== "codex") { + let next = text.trim() ? text : "{}\n"; + for (const write of writes) { + setSetting(parsed, write.path, write.value); + next = applyEdits(next, modify(next, write.path, write.value, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + })); + } + for (const root of ["env", "telemetry"]) { + if (getSetting(parsed, [root]) === undefined) { + next = applyEdits(next, modify(next, [root], undefined, {})); + } + } + return next; + } + for (const write of writes) setSetting(parsed, write.path, write.value); + const headers = tables(text); + if (Object.hasOwn(parseToml(text.slice(0, headers[0]?.start ?? text.length)), "otel")) { + throw new Error("Codex OTEL must use an [otel] table, not a root dotted or inline assignment."); + } + const newline = text.includes("\r\n") ? "\r\n" : "\n"; + const otel = parsed.otel as JsonObject | undefined; + const replacement = otel ? `[otel]${newline}${Object.entries(otel).map(([k, v]) => `${JSON.stringify(k)} = ${tomlValue(v)}${newline}`).join("")}` : ""; + let next = text; + const ranges = headers.map((h, i) => { + const end = headers[i + 1]?.start ?? text.length; + // Comments introducing the NEXT table are not part of the table we own. + const tail = /(?<=\n)(?:[ \t]*(?:#[^\r\n]*)?\r?\n)+$/.exec(text.slice(h.start, end)); + return { ...h, end: tail ? h.start + tail.index : end }; + }).filter(h => h.otel); + for (let i = ranges.length - 1; i >= 0; i--) { + const range = ranges[i]; + next = next.slice(0, range.start) + (i === 0 ? replacement : "") + next.slice(range.end); + } + if (!ranges.length && replacement) next += `${next && !next.endsWith("\n") ? newline : ""}${replacement}`; + parseToml(next); + return next; +} + +export function agentOtelWrites(agent: OtelAgent, endpoint: string, key: string, local: boolean, content: boolean): SettingWrite[] { + const auth = local ? {} : { Authorization: `Bearer ${key}` }; + const env: JsonObject = { + CLAUDE_CODE_ENABLE_TELEMETRY: "1", + OTEL_LOGS_EXPORTER: "otlp", OTEL_METRICS_EXPORTER: "otlp", + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_HEADERS: local ? "" : `Authorization=Bearer%20${encodeURIComponent(key)}`, + OTEL_LOG_USER_PROMPTS: content ? "1" : "0", + OTEL_LOG_TOOL_DETAILS: content ? "1" : "0", + OTEL_LOG_ASSISTANT_RESPONSES: content ? "1" : "0", + OTEL_LOG_TOOL_CONTENT: content ? "1" : "0", + }; + if (agent === "claude") { + for (const signal of ["LOGS", "METRICS"]) { + env[`OTEL_EXPORTER_OTLP_${signal}_ENDPOINT`] = `${endpoint}/v1/${signal.toLowerCase()}`; + env[`OTEL_EXPORTER_OTLP_${signal}_PROTOCOL`] = "http/protobuf"; + env[`OTEL_EXPORTER_OTLP_${signal}_HEADERS`] = env.OTEL_EXPORTER_OTLP_HEADERS; + } + return Object.entries(env).map(([k, value]) => ({ path: ["env", k], value })); + } + if (agent === "codex") { + const exporter = (signal: string) => ({ "otlp-http": { endpoint: `${endpoint}/v1/${signal}`, protocol: "json", headers: auth } }); + return Object.entries({ + exporter: exporter("logs"), trace_exporter: exporter("traces"), metrics_exporter: exporter("metrics"), + log_user_prompt: content, + log_agent_responses: content, + }).map(([k, value]) => ({ path: ["otel", k], value })); + } + if (agent === "gemini") return Object.entries({ + enabled: true, target: "local", otlpEndpoint: endpoint, otlpProtocol: "http", logPrompts: content, useCollector: true, outfile: undefined, + }).map(([k, value]) => ({ path: ["telemetry", k], value })); + return Object.entries({ + enabled: true, exporterType: "otlp-http", otlpEndpoint: endpoint, captureContent: content, + }).map(([k, value]) => ({ path: [`github.copilot.chat.otel.${k}`], value })); +} diff --git a/src/hooks/safe-config-write.ts b/src/hooks/safe-config-write.ts index ecf7cf603..74d1593ea 100644 --- a/src/hooks/safe-config-write.ts +++ b/src/hooks/safe-config-write.ts @@ -53,6 +53,8 @@ export function configBackupPath(path: string): string { /** Hooks for tests to simulate an interruption at the one step that matters. */ export interface AtomicWriteDeps { + /** Explicit permissions for a newly credential-bearing file. */ + mode?: number; rename?: (from: string, to: string) => void; /** Keep `.failproofai-backup` of the previous version (default true). */ backup?: boolean; @@ -144,9 +146,9 @@ export function writeConfigFileAtomic(path: string, content: string, deps: Atomi let mode: number | undefined; try { // A link being replaced lends nothing: its target's mode is someone else's. - mode = replacingLink ? undefined : statSync(target).mode & 0o777; + mode = deps.mode ?? (replacingLink ? undefined : statSync(target).mode & 0o777); } catch { - mode = undefined; // a new file: the process umask decides, as writeFileSync would + mode = deps.mode; // otherwise the process umask decides, as writeFileSync would } const temporary = join(dir, `.${basename(target)}.failproofai-${process.pid}-${randomBytes(4).toString("hex")}.tmp`); From 0eaf89e38715605781cf04fab650007b7a4d4a6c Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:57 +0000 Subject: [PATCH 02/14] feat(otel): add durable loopback relay and per-agent transcript lanes --- CHANGELOG.md | 6 + Cargo.lock | 60 ++ crates/failproofaid/src/main.rs | 16 +- crates/failproofaid/tests/otlp_relay_e2e.rs | 148 ++++ crates/fpai-collect/Cargo.toml | 4 +- crates/fpai-collect/src/config.rs | 47 ++ crates/fpai-collect/src/lib.rs | 1 + crates/fpai-collect/src/otlp.rs | 710 ++++++++++++++++++++ docs/agenteye/opentelemetry.mdx | 130 ++++ docs/docs.json | 2 + docs/reference/failproof-cli.mdx | 1 + docs/reference/otel.mdx | 67 ++ 12 files changed, 1190 insertions(+), 2 deletions(-) create mode 100644 crates/failproofaid/tests/otlp_relay_e2e.rs create mode 100644 crates/fpai-collect/src/otlp.rs create mode 100644 docs/agenteye/opentelemetry.mdx create mode 100644 docs/reference/otel.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index e1576b070..5a20f6cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.11-beta.0 — 2026-10-10 + +### Features + +- Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (PR pending). + ## 1.0.10 — 2026-10-05 Action needed if you use `instruct` policies with Hermes: an `instruct` no longer stops the call it reminds about. The call runs and the reminder is placed first in its result, so a policy written to prevent an action (for example, a direct API write that should go through an approved script) must become a `deny` before you upgrade. Hermes 0.20.0 is supported again: on 1.0.9 the plugin loaded there with no hooks and every tool call ran unchecked. After upgrading, run `failproofai update` and restart each Hermes gateway profile by name. Collects 1.0.10-beta.0 to beta.1 below. diff --git a/Cargo.lock b/Cargo.lock index 943a3344c..fd3a0981b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,52 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -283,7 +329,9 @@ dependencies = [ name = "fpai-collect" version = "1.0.11-beta.0" dependencies = [ + "axum", "getrandom 0.3.4", + "httpdate", "notify", "reqwest", "rusqlite", @@ -844,12 +892,24 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.2" diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index 10e386723..c2fb565e3 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -886,7 +886,13 @@ fn collector_tasks() -> Vec { })); } - if cfg.settings.sessions { + if cfg.settings.sessions + || cfg + .settings + .agents + .values() + .any(|a| a.sessions == Some(true)) + { // Session transcripts, gated on the `sessions` opt-in because — unlike // hook activity — these carry prompts, file contents and whatever was // pasted into a terminal. @@ -1252,6 +1258,14 @@ fn collector_tasks() -> Vec { } } + // Filter only transcript tasks; hook activity and delivery are untouched. + tasks.retain(|task| { + let source = task.name.split(':').next().unwrap_or(&task.name); + !HARNESS_KEYS.contains(&source) && source != "claude-subagent" + || cfg.settings.sessions_for(source) + }); + tasks.extend(fpai_collect::otlp::tasks(home, ingest, &cfg.settings.otlp)); + tasks.extend([ // Latency: delivers a batch within milliseconds of it being published. fpai_collect::TaskSpec::new("spool-watcher", move |sd| { diff --git a/crates/failproofaid/tests/otlp_relay_e2e.rs b/crates/failproofaid/tests/otlp_relay_e2e.rs new file mode 100644 index 000000000..3cc43774e --- /dev/null +++ b/crates/failproofaid/tests/otlp_relay_e2e.rs @@ -0,0 +1,148 @@ +//! A relay bind failure and live enable/disable must never share fate with the +//! daemon's enforcement socket. Real binary, loopback HTTP and Unix Ping. +use std::io::{BufRead, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use fpai_ipc::{ClientMessage, PROTOCOL_VERSION, ServerMessage, read_message, write_message}; + +struct Daemon { + child: Child, + home: PathBuf, + log: Arc>, +} +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.home); + } +} +fn settings(home: &Path, port: u16, enabled: bool) { + let body = serde_json::json!({ + "mode": {"kind":"cloud"}, "collector": { + "sessions": true, "agents": {"claude":{"sessions":false},"codex":{"sessions":false}}, + "hooks": false, "otlp": {"enabled":enabled,"port":port} + }, "telemetry":{"enabled":false}, "audit":{"auto":false} + }); + std::fs::write(home.join("config.json"), body.to_string()).unwrap(); +} +fn start(port: u16, enabled: bool) -> Daemon { + let home = std::env::temp_dir().join(format!( + "fpai-otlp-daemon-{}", + fpai_collect::uploader::new_request_id() + )); + std::fs::create_dir_all(&home).unwrap(); + settings(&home, port, enabled); + std::fs::write( + home.join("credentials.json"), + r#"{"ingest":{"url":"http://127.0.0.1:59999/v1/events","key":"test"}}"#, + ) + .unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_failproofaid")) + .env("HOME", &home) + .env("FAILPROOFAI_HOME", &home) + .env( + "FAILPROOFAI_DAEMON_SOCKET", + home.join("run/failproofaid.sock"), + ) + .env("FAILPROOFAI_TELEMETRY_DISABLED", "1") + .env("FAILPROOFAI_COLLECTOR_CONFIG_POLL_MS", "100") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let log = Arc::new(Mutex::new(String::new())); + let sink = log.clone(); + let pipe = child.stderr.take().unwrap(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(pipe).lines().map_while(Result::ok) { + let mut log = sink.lock().unwrap(); + log.push_str(&line); + log.push('\n'); + } + }); + Daemon { child, home, log } +} +fn wait(predicate: impl Fn() -> bool) { + let start = Instant::now(); + while !predicate() { + assert!(start.elapsed() < Duration::from_secs(20), "timed out"); + std::thread::sleep(Duration::from_millis(50)); + } +} +fn ping(daemon: &Daemon) { + let mut stream = UnixStream::connect(daemon.home.join("run/failproofaid.sock")).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert!(matches!(response, ServerMessage::Pong { .. })); +} + +#[test] +fn busy_port_logs_once_and_daemon_keeps_answering() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut daemon = start(port, true); + wait(|| daemon.log.lock().unwrap().contains("OTLP relay off:")); + ping(&daemon); + std::thread::sleep(Duration::from_millis(500)); + assert!(daemon.child.try_wait().unwrap().is_none()); + assert_eq!( + daemon + .log + .lock() + .unwrap() + .matches("OTLP relay off:") + .count(), + 1 + ); + let health = std::fs::read_to_string(daemon.home.join("state/otlp-health.json")).unwrap(); + assert!(health.contains("port_busy")); + // Both default and subagent Claude tasks must be absent. + let log = daemon.log.lock().unwrap(); + assert!(!log.contains("source=\"claude\"")); + assert!(!log.contains("source=\"claude-subagent\"")); + assert!(!log.contains("source=\"codex\"")); +} + +#[test] +fn relay_starts_and_stops_on_config_reload_without_daemon_restart() { + let reserved = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = reserved.local_addr().unwrap().port(); + drop(reserved); + let daemon = start(port, false); + wait(|| daemon.home.join("run/failproofaid.sock").exists()); + // The socket is bound before the collector manager snapshots its config. + // Wait for the first deployment before testing a subsequent hand edit. + wait(|| daemon.log.lock().unwrap().contains("collector started")); + std::thread::sleep(Duration::from_millis(250)); + ping(&daemon); + assert!(TcpStream::connect(("127.0.0.1", port)).is_err()); + settings(&daemon.home, port, true); + wait(|| TcpStream::connect(("127.0.0.1", port)).is_ok()); + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream.write_all(b"POST /v1/logs HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200")); + ping(&daemon); + settings(&daemon.home, port, false); + wait(|| TcpStream::connect(("127.0.0.1", port)).is_err()); + ping(&daemon); +} diff --git a/crates/fpai-collect/Cargo.toml b/crates/fpai-collect/Cargo.toml index 6edb91135..1131036c2 100644 --- a/crates/fpai-collect/Cargo.toml +++ b/crates/fpai-collect/Cargo.toml @@ -13,7 +13,9 @@ serde_json = "1" # The collector is the only async part of the daemon. `failproofaid`'s socket # server stays blocking std threads on purpose (see supervisor.rs), so this # runtime is created here and never leaks into the enforcement path. -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "sync", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "sync", "io-util", "net"] } +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +httpdate = "1" tracing = "0.1" # `rustls-tls` rather than the default native-tls: the daemon cross-compiles to # four targets and pulling OpenSSL onto each of them is the kind of build diff --git a/crates/fpai-collect/src/config.rs b/crates/fpai-collect/src/config.rs index 6acb75e80..feec16d1a 100644 --- a/crates/fpai-collect/src/config.rs +++ b/crates/fpai-collect/src/config.rs @@ -130,6 +130,12 @@ pub struct Settings { /// sending them. #[serde(default)] pub sessions: bool, + /// Absent per-agent override inherits the global sessions opt-in. + #[serde(default)] + pub agents: BTreeMap, + /// Raw OTLP relay is additive and disabled by default. + #[serde(default)] + pub otlp: OtlpSettings, /// Ship hook activity. Defaults to TRUE once ingest is configured — it /// carries decisions and tool names, never file contents, and it is the /// capability that justifies collecting from inside failproofai at all. @@ -175,6 +181,33 @@ pub struct SourceSettings { pub extra_paths: Vec, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct AgentSettings { + #[serde(default)] + pub sessions: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct OtlpSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_otlp_port")] + pub port: u16, +} + +fn default_otlp_port() -> u16 { + 4318 +} + +impl Default for OtlpSettings { + fn default() -> Self { + Self { + enabled: false, + port: default_otlp_port(), + } + } +} + fn default_true() -> bool { true } @@ -187,6 +220,8 @@ impl Default for Settings { fn default() -> Self { Settings { sessions: false, + agents: BTreeMap::new(), + otlp: OtlpSettings::default(), hooks: true, hooks_verbosity: HooksVerbosity::default(), redact: Redact::default(), @@ -198,6 +233,18 @@ impl Default for Settings { } impl Settings { + pub fn sessions_for(&self, agent: &str) -> bool { + let agent = if agent == "claude-subagent" { + "claude" + } else { + agent + }; + self.agents + .get(agent) + .and_then(|a| a.sessions) + .unwrap_or(self.sessions) + } + /// Raw `extra_paths` entries for one source, env override winning whole. /// /// `FAILPROOFAI__EXTRA_PATHS`, comma-separated — the same diff --git a/crates/fpai-collect/src/lib.rs b/crates/fpai-collect/src/lib.rs index bc1f1ce7c..9bc8fec86 100644 --- a/crates/fpai-collect/src/lib.rs +++ b/crates/fpai-collect/src/lib.rs @@ -17,6 +17,7 @@ pub mod delivery; pub mod extra_paths; pub mod filetail; pub mod health; +pub mod otlp; pub mod redact; pub mod sources; pub mod spool; diff --git a/crates/fpai-collect/src/otlp.rs b/crates/fpai-collect/src/otlp.rs new file mode 100644 index 000000000..9b24b0578 --- /dev/null +++ b/crates/fpai-collect/src/otlp.rs @@ -0,0 +1,710 @@ +//! Opaque OTLP/HTTP relay. No translation, decompression or event-uploader reuse. +//! Each batch is a JSON metadata line followed by the exact request bytes. +//! SDK exporters publish the same format; only atomic .jsonl files are read. +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Response; +use axum::routing::post; +use serde::{Deserialize, Serialize}; + +use crate::config::{Ingest, OtlpSettings}; +use crate::supervisor::{Shutdown, TaskError, TaskSpec}; +use crate::uploader::{ParkedName, new_request_id}; + +pub const MAX_BODY_BYTES: usize = 8 * 1024 * 1024; +const PATHS: [&str; 3] = ["/v1/traces", "/v1/logs", "/v1/metrics"]; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Metadata { + pub path: String, + pub content_type: String, + pub encoding: Option, +} + +#[derive(Default, Serialize, Deserialize)] +struct RelayHealth { + relay: String, + last_upload: Option, +} + +struct Relay { + spool: PathBuf, + failed: PathBuf, + health_file: PathBuf, + health: Mutex, + client: reqwest::Client, + origin: String, + key: String, + receiving: tokio::sync::Semaphore, +} + +impl Relay { + fn new(home: &Path, ingest: &Ingest) -> Result { + let url = reqwest::Url::parse(&ingest.url).map_err(|e| TaskError(e.to_string()))?; + let origin = url.origin().ascii_serialization(); + let health_file = home.join("state/otlp-health.json"); + let mut health = fs::read(&health_file) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .unwrap_or_default(); + health.relay = "off".to_string(); + Ok(Self { + spool: home.join("state/spool-otlp"), + // Nested under failed, isolated from the NDJSON events sweeper. + failed: home.join("state/failed/otlp"), + health_file, + health: Mutex::new(health), + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| TaskError(e.to_string()))?, + origin, + key: ingest.key.clone(), + receiving: tokio::sync::Semaphore::new(16), + }) + } + + fn report(&self, relay: Option<&str>, upload: Option) { + let Ok(mut health) = self.health.lock() else { + return; + }; + if let Some(relay) = relay { + health.relay = relay.to_string(); + } + if let Some(upload) = upload { + health.last_upload = Some(upload); + } + if let Ok(bytes) = serde_json::to_vec(&*health) { + let _ = atomic_write(&self.health_file, &bytes); + } + } + + async fn deliver(&self, path: &Path, sd: &Shutdown) { + let bytes = match tokio::fs::read(path).await { + Ok(bytes) => bytes, + Err(_) => return, + }; + let Some(split) = bytes.iter().position(|b| *b == b'\n') else { + self.park(path, None).await; + return; + }; + let metadata: Metadata = match serde_json::from_slice(&bytes[..split]) { + Ok(metadata) => metadata, + Err(_) => { + self.park(path, None).await; + return; + } + }; + if !PATHS.contains(&metadata.path.as_str()) || bytes.len() - split - 1 > MAX_BODY_BYTES { + self.park(path, Some(400)).await; + return; + } + let body = &bytes[split + 1..]; + let mut status = None; + for attempt in 0..5 { + if sd.is_set() { + return; + } + let mut request = self + .client + .post(format!("{}{}", self.origin, metadata.path)) + .bearer_auth(&self.key) + .header("Content-Type", &metadata.content_type) + .body(body.to_vec()); + if let Some(encoding) = &metadata.encoding { + request = request.header("Content-Encoding", encoding); + } + let response = request.send().await; + let mut delay = Duration::from_secs(1 << attempt); + match response { + Ok(response) => { + let code = response.status().as_u16(); + status = Some(code); + if code == 200 { + let content_type = response + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_string(); + // Bound response size too; an ingest proxy need not be trusted. + let mut response = response; + let mut result = Vec::new(); + while let Ok(Some(chunk)) = response.chunk().await { + if result.len() + chunk.len() > 64 * 1024 { + break; + } + result.extend_from_slice(&chunk); + } + let rejected = rejected_count(&result, &content_type); + if rejected > 0 { + tracing::warn!(rejected, signal = %metadata.path, "OTLP partial_success delivered"); + } + self.report(None, Some(format!("200 delivered; rejected={rejected}"))); + let _ = tokio::fs::remove_file(path).await; + return; + } + self.report(None, Some(format!("{code} parked"))); + if !matches!(code, 429 | 502 | 503 | 504) { + break; + } + delay = retry_after(response.headers()).unwrap_or(delay); + } + Err(_) => self.report(None, Some("network error; retained".to_string())), + } + if attempt < 4 && !sd.sleep(delay).await { + return; + } + } + self.park(path, status).await; + } + + async fn park(&self, path: &Path, status: Option) { + if tokio::fs::create_dir_all(&self.failed).await.is_err() { + return; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return; + }; + let mut parked = ParkedName::parse(name); + parked.attempt += 1; + parked.client_status = status.filter(|c| (400..500).contains(c) && *c != 429); + parked.poison = parked.attempt >= 3; + let target = self.failed.join(parked.render()); + if tokio::fs::rename(path, target).await.is_ok() { + let _ = File::open(&self.failed).and_then(|f| f.sync_all()); + } + } +} + +fn retry_after(headers: &HeaderMap) -> Option { + let value = headers.get("retry-after")?.to_str().ok()?; + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs(seconds)); + } + Some( + httpdate::parse_http_date(value) + .ok()? + .duration_since(SystemTime::now()) + .unwrap_or_default(), + ) +} + +fn varint(bytes: &[u8], cursor: &mut usize) -> Option { + let mut value = 0; + for shift in (0..64).step_by(7) { + let byte = *bytes.get(*cursor)?; + *cursor += 1; + value |= u64::from(byte & 127) << shift; + if byte & 128 == 0 { + return Some(value); + } + } + None +} + +/// Export response field 1 is partial_success; its field 1 is rejected count. +/// Only response metadata is read — payload translation belongs to Cloud. +fn rejected_count(bytes: &[u8], content_type: &str) -> u64 { + if content_type.contains("json") || bytes.first() == Some(&b'{') { + let Ok(value) = serde_json::from_slice::(bytes) else { + return 0; + }; + let partial = value + .get("partialSuccess") + .or_else(|| value.get("partial_success")); + return partial + .and_then(|p| { + [ + "rejectedSpans", + "rejectedLogRecords", + "rejectedDataPoints", + "rejected_spans", + "rejected_log_records", + "rejected_data_points", + ] + .iter() + .find_map(|key| { + p.get(key) + .and_then(|n| n.as_u64().or_else(|| n.as_str()?.parse().ok())) + }) + }) + .unwrap_or(0); + } + let mut cursor = 0; + if varint(bytes, &mut cursor) != Some(10) { + return 0; + } + let Some(length) = varint(bytes, &mut cursor) else { + return 0; + }; + let Some(end) = cursor.checked_add(length as usize) else { + return 0; + }; + let Some(partial) = bytes.get(cursor..end) else { + return 0; + }; + cursor = 0; + if varint(partial, &mut cursor) != Some(8) { + return 0; + } + varint(partial, &mut cursor).unwrap_or(0) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let dir = path + .parent() + .ok_or_else(|| io::Error::other("missing directory"))?; + fs::create_dir_all(dir)?; + let temp = dir.join(format!(".{}.tmp", new_request_id())); + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temp, path)?; + File::open(dir)?.sync_all() + })(); + if result.is_err() { + let _ = fs::remove_file(temp); + } + result +} + +pub fn spool_request(spool: &Path, metadata: &Metadata, body: &[u8]) -> io::Result { + if !PATHS.contains(&metadata.path.as_str()) || body.len() > MAX_BODY_BYTES { + return Err(io::Error::other("invalid OTLP path or body size")); + } + let mut bytes = serde_json::to_vec(metadata)?; + bytes.push(b'\n'); + bytes.extend_from_slice(body); + let path = spool.join(format!("otlp-{}.jsonl", new_request_id())); + atomic_write(&path, &bytes)?; + Ok(path) +} + +async fn receive(State(relay): State>, request: Request) -> Response { + let Ok(_permit) = relay.receiving.try_acquire() else { + return Response::builder().status(503).body(Body::empty()).unwrap(); + }; + let path = request.uri().path().to_string(); + let content_type = request + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or("application/x-protobuf") + .to_string(); + if !matches!( + content_type.split(';').next(), + Some("application/json" | "application/x-protobuf" | "application/protobuf") + ) { + return Response::builder().status(415).body(Body::empty()).unwrap(); + } + let encoding = request + .headers() + .get("content-encoding") + .and_then(|h| h.to_str().ok()) + .map(str::to_string); + let is_json = content_type.contains("json"); + let body = match tokio::time::timeout( + Duration::from_secs(30), + to_bytes(request.into_body(), MAX_BODY_BYTES), + ) + .await + { + Ok(Ok(body)) => body, + Ok(Err(_)) => { + return Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .body(Body::empty()) + .unwrap(); + } + Err(_) => { + return Response::builder() + .status(StatusCode::REQUEST_TIMEOUT) + .body(Body::empty()) + .unwrap(); + } + }; + let spool = relay.spool.clone(); + let metadata = Metadata { + path, + content_type, + encoding, + }; + match tokio::task::spawn_blocking(move || spool_request(&spool, &metadata, &body)).await { + Ok(Ok(_)) => Response::builder() + .status(200) + .header( + "Content-Type", + if is_json { + "application/json" + } else { + "application/x-protobuf" + }, + ) + .body(Body::from(if is_json { + b"{}".as_slice() + } else { + b"".as_slice() + })) + .unwrap(), + _ => Response::builder().status(503).body(Body::empty()).unwrap(), + } +} + +async fn listen(relay: Arc, port: u16, sd: Shutdown) -> Result<(), TaskError> { + // Binding an IPv4 address, never a configurable host, makes loopback-only + // structural: no config or environment variable can expose the listener. + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let listener = match tokio::net::TcpListener::bind(address).await { + Ok(listener) => listener, + Err(err) => { + tracing::warn!(port, %err, "OTLP relay off: loopback port unavailable; daemon continues"); + relay.report(Some("port_busy"), None); + return Ok(()); // Clean exit: supervisor does not restart/log repeatedly. + } + }; + relay.report(Some("listening"), None); + let app = Router::new() + .route("/v1/traces", post(receive)) + .route("/v1/logs", post(receive)) + .route("/v1/metrics", post(receive)) + .with_state(relay.clone()); + let result = axum::serve(listener, app) + .with_graceful_shutdown(async move { while sd.sleep(Duration::from_millis(100)).await {} }) + .await; + relay.report(Some("off"), None); + result.map_err(|e| TaskError(e.to_string())) +} + +async fn deliver_loop(relay: Arc, sd: Shutdown) -> Result<(), TaskError> { + let mut last_failed = tokio::time::Instant::now(); + while !sd.is_set() { + for directory in [&relay.spool, &relay.failed] { + if directory == &relay.failed && last_failed.elapsed() < Duration::from_secs(3600) { + continue; + } + if directory == &relay.failed { + last_failed = tokio::time::Instant::now(); + } + let Ok(mut entries) = tokio::fs::read_dir(directory).await else { + continue; + }; + let mut delivered = 0; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with("otlp-") || !name.ends_with(".jsonl") { + continue; + } + if entry.file_type().await.is_ok_and(|t| t.is_file()) { + relay.deliver(&entry.path(), &sd).await; + delivered += 1; + if delivered >= 32 || sd.is_set() { + break; + } + } + } + } + if !sd.sleep(Duration::from_millis(250)).await { + break; + } + } + Ok(()) +} + +pub fn tasks(home: PathBuf, ingest: Ingest, settings: &OtlpSettings) -> Vec { + if !settings.enabled { + return Vec::new(); + } + let relay = match Relay::new(&home, &ingest) { + Ok(relay) => Arc::new(relay), + Err(err) => { + tracing::warn!(%err, "OTLP relay disabled"); + return Vec::new(); + } + }; + let delivery = relay.clone(); + let port = settings.port; + vec![ + TaskSpec::new("otlp-relay", move |sd| listen(relay.clone(), port, sd)), + TaskSpec::new("otlp-delivery", move |sd| { + deliver_loop(delivery.clone(), sd) + }), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, + }; + + fn temp() -> PathBuf { + let dir = std::env::temp_dir().join(format!("fpai-otlp-{}", new_request_id())); + fs::create_dir_all(&dir).unwrap(); + dir + } + fn shutdown() -> Shutdown { + Shutdown::for_test(Arc::new(AtomicBool::new(false))) + } + fn batch(relay: &Relay, body: &[u8]) -> PathBuf { + spool_request( + &relay.spool, + &Metadata { + path: "/v1/logs".into(), + content_type: "application/json".into(), + encoding: Some("gzip".into()), + }, + body, + ) + .unwrap() + } + + #[tokio::test] + async fn durable_opaque_forward_and_partial_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/logs")) + .and(header("authorization", "Bearer test")) + .and(header("content-type", "application/json")) + .and(header("content-encoding", "gzip")) + .respond_with( + ResponseTemplate::new(200).set_body_json( + serde_json::json!({"partialSuccess":{"rejectedLogRecords":"2"}}), + ), + ) + .expect(1) + .mount(&server) + .await; + let home = temp(); + let relay = Relay::new( + &home, + &Ingest { + url: format!("{}/v1/events", server.uri()), + key: "test".into(), + }, + ) + .unwrap(); + let body = b"\x1f\x8b\x00opaque-gzip"; + let file = batch(&relay, body); + let stored = fs::read(&file).unwrap(); + assert!(stored.ends_with(body)); + relay.deliver(&file, &shutdown()).await; + assert!(!file.exists()); + assert_eq!(server.received_requests().await.unwrap()[0].body, body); + assert!( + fs::read_to_string(&relay.health_file) + .unwrap() + .contains("rejected=2") + ); + fs::remove_dir_all(home).unwrap(); + } + + #[tokio::test] + async fn retries_503_retry_after_then_delivers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(503).insert_header("Retry-After", "1")) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let home = temp(); + let relay = Relay::new( + &home, + &Ingest { + url: server.uri(), + key: "test".into(), + }, + ) + .unwrap(); + let file = batch(&relay, b"{}"); + let start = tokio::time::Instant::now(); + relay.deliver(&file, &shutdown()).await; + assert!(start.elapsed() >= Duration::from_secs(1)); + assert!(!file.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[tokio::test] + async fn parks_400_and_404_without_retry_or_loss() { + for code in [400, 404] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(code)) + .expect(1) + .mount(&server) + .await; + let home = temp(); + let relay = Relay::new( + &home, + &Ingest { + url: server.uri(), + key: "test".into(), + }, + ) + .unwrap(); + let file = batch(&relay, b"unchanged"); + let original = fs::read(&file).unwrap(); + relay.deliver(&file, &shutdown()).await; + assert!(!file.exists()); + let parked = fs::read_dir(&relay.failed) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert!( + parked + .to_str() + .unwrap() + .contains(&format!(".a1.c{code}.jsonl")) + ); + assert_eq!(fs::read(&parked).unwrap(), original); + fs::remove_dir_all(home).unwrap(); + } + } + + #[tokio::test] + async fn busy_port_exits_cleanly_without_supervisor_failure() { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + let home = temp(); + let relay = Arc::new( + Relay::new( + &home, + &Ingest { + url: "http://127.0.0.1:1".into(), + key: "test".into(), + }, + ) + .unwrap(), + ); + assert!(listen(relay.clone(), port, shutdown()).await.is_ok()); + assert!( + fs::read_to_string(&relay.health_file) + .unwrap() + .contains("port_busy") + ); + fs::remove_dir_all(home).unwrap(); + } + + #[tokio::test] + async fn loopback_receiver_limits_and_acknowledges_only_durable_batches() { + let home = temp(); + let relay = Arc::new( + Relay::new( + &home, + &Ingest { + url: "http://127.0.0.1:1".into(), + key: "test".into(), + }, + ) + .unwrap(), + ); + let reserved = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let port = reserved.local_addr().unwrap().port(); + drop(reserved); + let flag = Arc::new(AtomicBool::new(false)); + let task = tokio::spawn(listen( + relay.clone(), + port, + Shutdown::for_test(flag.clone()), + )); + let client = reqwest::Client::new(); + let url = format!("http://127.0.0.1:{port}/v1/traces"); + let mut response = None; + for _ in 0..30 { + match client + .post(&url) + .header("Content-Type", "application/x-protobuf") + .body(vec![0, 255, 1]) + .send() + .await + { + Ok(r) => { + response = Some(r); + break; + } + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + assert_eq!(response.unwrap().status(), 200); + let stored = fs::read_dir(&relay.spool) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert!(fs::read(stored).unwrap().ends_with(&[0, 255, 1])); + assert_eq!( + client + .post(url) + .body(vec![0; MAX_BODY_BYTES + 1]) + .send() + .await + .unwrap() + .status(), + 413 + ); + flag.store(true, std::sync::atomic::Ordering::Relaxed); + task.await.unwrap().unwrap(); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn protobuf_partial_success_and_retry_dates() { + assert_eq!(rejected_count(&[10, 2, 8, 3], "application/x-protobuf"), 3); + assert_eq!(rejected_count(&[], "application/x-protobuf"), 0); + let mut headers = HeaderMap::new(); + headers.insert( + "retry-after", + httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(10)) + .parse() + .unwrap(), + ); + assert!(retry_after(&headers).unwrap() >= Duration::from_secs(8)); + } + + #[test] + fn per_agent_overrides_and_relay_are_opt_in() { + let settings: crate::config::Settings = serde_json::from_value(serde_json::json!({ + "sessions": true, "agents": { "claude": { "sessions": false }, "codex": {} } + })) + .unwrap(); + assert!(!settings.sessions_for("claude")); + assert!(!settings.sessions_for("claude-subagent")); + assert!(settings.sessions_for("codex")); + assert!(!settings.otlp.enabled); + assert_eq!(settings.otlp.port, 4318); + } +} diff --git a/docs/agenteye/opentelemetry.mdx b/docs/agenteye/opentelemetry.mdx new file mode 100644 index 000000000..adf5237e9 --- /dev/null +++ b/docs/agenteye/opentelemetry.mdx @@ -0,0 +1,130 @@ +--- +title: "OpenTelemetry" +description: "Send coding-agent and application telemetry to FailproofAI Cloud with OTLP." +icon: "radio" +--- + +OpenTelemetry lets you send agent traces, logs, and metrics to FailproofAI Cloud using the standard OTLP/HTTP protocol. It is optional. Your existing FailproofAI integrations continue working without enabling it. + +## Turn on ingest for your organisation + +An organisation admin opens **Settings → OpenTelemetry** and turns on ingest. Use a Cloud API key with `events:add` permission, the same permission used for agent reporting. + +If ingest is off, Cloud refuses OTLP exports. Your local setup can still be prepared; run `failproofai otel status` to check the organisation switch before starting an agent. + +## Connect a coding agent + +Connect your machine with `failproofai config`, then choose an agent: + +```bash +failproofai otel enable claude +failproofai otel enable codex +failproofai otel enable gemini +failproofai otel enable copilot +``` + +The setup command chooses Cloud or a local authenticated relay as appropriate. Gemini CLI and Copilot use the local relay automatically. Start your configured FailproofAI daemon before using those integrations. + +If FailproofAI already sends an agent's session transcripts, setup asks whether to switch that agent to OTEL. The default is No. Accepting stops transcript uploads for that agent; it does not change policy enforcement or reporting from other agents. + +## Connect an application + +Use your application's OpenTelemetry instrumentation with these environment variables: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=https://app.befailproof.ai +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +export OTEL_EXPORTER_OTLP_HEADERS='Authorization=Bearer%20' +export OTEL_SERVICE_NAME=my-agent +``` + +Replace `` with your API key. Keep the secret out of shell history, screen recordings, and committed configuration. For a configured machine, `failproofai otel env --service my-agent` prints the block with its existing key. Its output contains a credential. + +Cloud accepts OTLP/HTTP exports for traces, logs, and metrics, as protobuf or JSON, including gzip-compressed requests. It does not accept OTLP/gRPC. + +## Capture prompt text + + +Text capture can include prompts, tool arguments, and other sensitive content. `failproofai otel enable` turns it on. Add `--no-content` to keep prompt and tool-detail capture off. + + +```bash +failproofai otel enable claude --no-content +failproofai otel enable codex --no-content +``` + +For an agent you configure manually, use its own content-capture controls: + + + + Add these variables to your agent's environment alongside the application block above: + + ```bash + export CLAUDE_CODE_ENABLE_TELEMETRY=1 + export OTEL_LOGS_EXPORTER=otlp + export OTEL_METRICS_EXPORTER=otlp + export OTEL_LOG_USER_PROMPTS=1 + export OTEL_LOG_TOOL_DETAILS=1 + ``` + + Set both content variables to `0` to avoid capturing prompts and tool details. + + + In Codex's user configuration, use its OTEL settings: + + ```toml + [otel] + log_user_prompt = true + exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/v1/logs", protocol = "json", headers = { Authorization = "Bearer " } } } + trace_exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/v1/traces", protocol = "json", headers = { Authorization = "Bearer " } } } + metrics_exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/v1/metrics", protocol = "json", headers = { Authorization = "Bearer " } } } + ``` + + Codex uses the complete endpoint you provide. Set `log_user_prompt` to `false` to leave prompts out. + + + Use Gemini's telemetry settings with the local relay: + + ```json + { + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "http://127.0.0.1:4318", + "otlpProtocol": "http", + "useCollector": true, + "logPrompts": true + } + } + ``` + + Run `failproofai otel enable gemini` to enable the authenticated relay. Set `logPrompts` to `false` for no prompt capture. + + + Use these VS Code user settings: + + ```json + { + "github.copilot.chat.otel.enabled": true, + "github.copilot.chat.otel.exporterType": "otlp-http", + "github.copilot.chat.otel.otlpEndpoint": "http://127.0.0.1:4318", + "github.copilot.chat.otel.captureContent": true + } + ``` + + Run `failproofai otel enable copilot` to enable the authenticated relay. Set `captureContent` to `false` to omit content. + + + +## Check or undo setup + +```bash +failproofai otel status +failproofai otel disable all +``` + +Status distinguishes settings managed by FailproofAI from telemetry you configured elsewhere. Disable restores the previous managed settings and the agent's previous transcript-upload choice. + + + Command options, local relay setup, and troubleshooting. + diff --git a/docs/docs.json b/docs/docs.json index ec8079b32..50ddf63e7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -122,6 +122,7 @@ "group": "Plug in your agent", "pages": [ "start/integrations", + "agenteye/opentelemetry", "start/integrations/custom-agents", "start/integrations/langchain", "start/integrations/crewai", @@ -241,6 +242,7 @@ "expanded": false, "pages": [ "reference/failproof-cli", + "reference/otel", "reference/local-dashboard", "policies/local-configuration", "reference/cloud-cli", diff --git a/docs/reference/failproof-cli.mdx b/docs/reference/failproof-cli.mdx index e9eabfd50..fc30731e0 100644 --- a/docs/reference/failproof-cli.mdx +++ b/docs/reference/failproof-cli.mdx @@ -58,6 +58,7 @@ Run `failproofai` without arguments to open the local policy dashboard. | `failproofai audit --status` | Show the report address, interval, and next scheduled scan | | `failproofai audit --no-schedule` | Stop recurring scans without deleting audit history | | `failproofai harness list` | List extra capture paths | +| `failproofai otel status` | Inspect optional [OpenTelemetry reporting](/reference/otel) | | `failproofai jev --url --key-stdin` | Set Jev up in one step; the provider is taken from the URL's host | | `failproofai jev setup --provider --key-stdin` | Let [Jev](/reference/jev-providers) judge tool calls through your own endpoint and key | | `failproofai jev setup --provider failproofai` | Let Jev judge tool calls [through FailproofAI Cloud](/reference/jev-cloud), with this machine's Cloud key | diff --git a/docs/reference/otel.mdx b/docs/reference/otel.mdx new file mode 100644 index 000000000..462d2d303 --- /dev/null +++ b/docs/reference/otel.mdx @@ -0,0 +1,67 @@ +--- +title: "OpenTelemetry CLI" +description: "Opt in to OTEL for coding agents, inspect delivery, and restore your previous settings." +icon: "radio" +--- + +`failproofai otel` configures optional OpenTelemetry reporting to FailproofAI Cloud. First connect your machine with `failproofai config` and ask an organisation admin to enable **Settings → OpenTelemetry**. + +## Enable an agent + +```bash +failproofai otel enable claude +failproofai otel enable codex --local +failproofai otel enable gemini --no-content +failproofai otel enable copilot +failproofai otel enable all --yes +``` + +The supported agents are `claude`, `codex`, `gemini`, and `copilot` (VS Code). If VS Code has not been launched on the machine, setup skips Copilot and tells you why. + +`--local` sends to the local relay instead of directly to Cloud. Gemini CLI and Copilot use the relay automatically because their settings cannot store the authentication header. + +The relay listens only on your machine. Your configured FailproofAI daemon picks up the change within seconds; you do not need to restart it. If another application owns the relay port, status reports it as off and the daemon keeps running. + + +Text capture is on by default. Add `--no-content` to omit prompts and tool details. Agent versions can expose different content controls; review what your agent exports before using it with sensitive data. + + +### Choose one reporting path + +If transcript reporting is on for an agent, setup asks: + +> failproofai already sends Claude Code sessions to Cloud. Send them with OTEL instead? Transcript upload for Claude Code stops. + +The default is No. `--yes` accepts the switch without a prompt. Other agents and policy enforcement are unaffected. + +## Inspect setup + +```bash +failproofai otel status +``` + +Status shows every agent, whether its settings are managed by FailproofAI, its destination, the organisation's ingest switch, relay state, and the last relay upload result. + +`unknown` for the organisation switch means the server did not provide it or could not be reached. It is not an error and does not prevent configuration on an older Cloud version. + +If Cloud does not support OTEL yet, the relay retains rejected exports instead of discarding them. If organisation ingest is off, ask an admin to turn it on before starting your agents. + +## Print configuration for an application + +```bash +failproofai otel env --service my-agent +failproofai otel env --service my-agent --local +``` + +The output is a shell environment block for an OTLP/HTTP exporter. Cloud output includes the configured API key; treat it as a secret. Local output omits the key. To use local output, first enable a local agent so the relay is running. + +## Restore previous settings + +```bash +failproofai otel disable claude +failproofai otel disable all +``` + +Disable restores only the settings FailproofAI wrote, keeps unrelated changes, and restores each agent's prior transcript-reporting choice. When the last managed local agent is disabled, the relay stops accepting exports. + +Every command supports `--help`. See [OpenTelemetry in Cloud](/agenteye/opentelemetry) for application and coding-agent examples. From 240aec6d43327e2cdcb854e3098de2dbb9e906d2 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:33:45 +0000 Subject: [PATCH 03/14] docs(otel): link changelog to draft PR 880 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a20f6cd3..af796d156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (PR pending). +- Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (#880). ## 1.0.10 — 2026-10-05 From aa5c771e78441f561017b87b07ab6598e7e14f48 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:37:43 +0000 Subject: [PATCH 04/14] fix(ci): update vulnerable existing build and SDK test dependencies --- CHANGELOG.md | 4 + bun.lock | 81 +++---- package.json | 5 +- sdk/python/uv.lock | 397 +++++++++++++++++++------------ sdk/typescript/package-lock.json | 6 +- 5 files changed, 302 insertions(+), 191 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af796d156..6d77b78c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (#880). +### Dependencies + +- Update vulnerable existing build and SDK test lockfile dependencies reported by the PR's OSV scan (#880). + ## 1.0.10 — 2026-10-05 Action needed if you use `instruct` policies with Hermes: an `instruct` no longer stops the call it reminds about. The call runs and the reminder is placed first in its result, so a policy written to prevent an action (for example, a direct API write that should go through an approved script) must become a `deny` before you upgrade. Hermes 0.20.0 is supported again: on 1.0.9 the plugin loaded there with no hooks and every tool call ran unchecked. After upgrading, run `failproofai update` and restart each Hermes gateway profile by name. Collects 1.0.10-beta.0 to beta.1 below. diff --git a/bun.lock b/bun.lock index 1a88d0312..ccf6a2cf9 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "eslint-config-next": "^16.3.2", "jsdom": "^30.0.1", "lucide-react": "^1.33.0", - "next": "^16.3.2", + "next": "16.3.8", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.4.0", @@ -45,7 +45,8 @@ "eslint-plugin-react-hooks": "7.0.1", "nanoid": "3.3.18", "postcss": "8.5.26", - "sharp": "0.35.4", + "sharp": "0.35.5", + "source-map-js": "1.2.2", "undici": "7.29.1", "vite": "8.0.16", }, @@ -146,57 +147,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -216,25 +217,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="], + "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.6", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -982,7 +983,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="], + "next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], @@ -1106,7 +1107,7 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1124,7 +1125,7 @@ "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], diff --git a/package.json b/package.json index 32d8fa621..b36778204 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "eslint-config-next": "^16.3.2", "jsdom": "^30.0.1", "lucide-react": "^1.33.0", - "next": "^16.3.2", + "next": "16.3.8", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.4.0", @@ -120,7 +120,8 @@ "vite": "8.0.16", "undici": "7.29.1", "brace-expansion": "5.0.12", - "sharp": "0.35.4", + "sharp": "0.35.5", + "source-map-js": "1.2.2", "browserslist": "4.28.8" } } diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 106a304d4..3f4607c88 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -272,7 +272,7 @@ wheels = [ [[package]] name = "banks" -version = "2.5.0" +version = "2.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated" }, @@ -281,10 +281,11 @@ dependencies = [ { name = "jinja2" }, { name = "platformdirs" }, { name = "pydantic" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/f0/ce5b3105a8551fdcedb509ab5340066247b3cd1b28e79f9296be2d6d3bf4/banks-2.5.0.tar.gz", hash = "sha256:fdd4fd54b84dbe31cb51a1173c960697c73d683a52fb0b1d1957a557a8d6fcc8", size = 194585, upload-time = "2026-08-08T15:54:16.548Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/92/0449c32e718c10e4af40a3e040c830a77cdaeec6e9066622ee60e4c93e6e/banks-2.5.1.tar.gz", hash = "sha256:a7ca5c250b605ae90cfd6e4ca5dd6ce2ee377d5d59021b6feba44e345910026d", size = 196398, upload-time = "2026-09-10T10:15:34.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/9c/7dc7b15cecc03baa47d413b2599487b1fab270169b00feaa5e74978845f3/banks-2.5.0-py3-none-any.whl", hash = "sha256:8804c58f23e41a5aabe9ecfdf64235cdf1d5f3b16ad95ec2a54b6dc738dc1dfc", size = 38620, upload-time = "2026-08-08T15:54:15.505Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6f/06c0824ee2611de47a0fc731fa57f6042d3f573025d9f504adfd65e50d51/banks-2.5.1-py3-none-any.whl", hash = "sha256:b7c91365626bab4b46197d24ce04cd69540730ed669a50a399f240318ba3dcfb", size = 39462, upload-time = "2026-09-10T10:15:35.606Z" }, ] [[package]] @@ -1025,7 +1026,8 @@ llamaindex = [ { name = "llama-index-core" }, ] pydantic-ai = [ - { name = "pydantic-ai-slim" }, + { name = "pydantic-ai-slim", version = "2.54.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pydantic-ai-slim", version = "2.55.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.metadata] @@ -2079,7 +2081,7 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.4.2" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2088,9 +2090,9 @@ dependencies = [ { name = "orjson" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/85/54edf4902c0378510f09a009f64e3d3f5644aeeb51d13cf67ec380a5245b/langgraph_sdk-0.4.6.tar.gz", hash = "sha256:ea5b1749d70987d741c9b9fe2b7e1c5f7da7e4d102f64e390333c1890c9bbd5b", size = 350632, upload-time = "2026-10-06T14:17:53.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c7/69d13026db3577c157877dea47a9dc5fffd3dcde2d1ab8009fa8f7e5f6a6/langgraph_sdk-0.4.6-py3-none-any.whl", hash = "sha256:57122b02bda41d37a97a5e09eb018aa8c9b3dc85c433880073779bd9324b7eea", size = 162390, upload-time = "2026-10-06T14:17:52.134Z" }, ] [[package]] @@ -2502,140 +2504,183 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.1" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, - { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, - { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, - { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, - { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, - { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, - { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, - { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/1c/fb2114207afc7f00ab9897ae5beeee27de5ec58ea8b9623e879f2c56f003/multidict-6.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8", size = 97307, upload-time = "2026-09-21T17:54:14.957Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/1ab8c7349adbb30c8cf41b88b0794740df8ea9f7de2e382da040a2f5bd8e/multidict-6.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19", size = 58830, upload-time = "2026-09-21T17:54:16.261Z" }, + { url = "https://files.pythonhosted.org/packages/e0/89/b845535014a9ad50537ca50204aaf6351c5368822a3acd9da854fb2a5a34/multidict-6.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa", size = 57645, upload-time = "2026-09-21T17:54:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4f/28ebb40a6f8e8aa7be78bcbe4bf9a1ac9518c40ef88accf2d4cd6ed7a7e0/multidict-6.9.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299", size = 301535, upload-time = "2026-09-21T17:54:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/33/51/0de5fc980df34d1ffed9ef68f60ce86c2d4baa3d422598a53f8730c56757/multidict-6.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96", size = 298796, upload-time = "2026-09-21T17:54:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/dd86067bdd2e961fdee8ec659fabc50f4e76403616b508714ec2686b0a49/multidict-6.9.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188", size = 282392, upload-time = "2026-09-21T17:54:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/2f/52/5464a2675579e3adb48b7cd4707a59da499660f0e22c024bc7281d2d9bdf/multidict-6.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a", size = 314538, upload-time = "2026-09-21T17:54:23.366Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/7f2b3fafe064181669157ce872a097b08a0546fb6fd2481e8c5604b47ec3/multidict-6.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e", size = 315675, upload-time = "2026-09-21T17:54:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/f65ed68b96eea2b4f6fa4f9031bdfccd8f3097c429e3d371c545d1c40ca4/multidict-6.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab", size = 303685, upload-time = "2026-09-21T17:54:26.236Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5f/ade05795c6fb9adfccd61695a0f0509bc793583f589c4b1b87f27215a139/multidict-6.9.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88", size = 280306, upload-time = "2026-09-21T17:54:27.685Z" }, + { url = "https://files.pythonhosted.org/packages/60/b2/c996349a61e0f43c7c95877943a67476261bf906ff993670afbd33ff5bf4/multidict-6.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1", size = 291691, upload-time = "2026-09-21T17:54:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/8b7016d1084854729c80acfae71c5a51ea7e7c9411d66c3e2729772f5723/multidict-6.9.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c", size = 290357, upload-time = "2026-09-21T17:54:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/801653b6aa9343a7430cecdefa1bf71ff24432224c02f15cc15e47837652/multidict-6.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126", size = 305831, upload-time = "2026-09-21T17:54:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f3/9fc997861920bdbf70db82bdce5c802ec6477754c4bd78cff2898e3c63f9/multidict-6.9.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410", size = 308995, upload-time = "2026-09-21T17:54:33.571Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5f/278b5a2c99d6de27914b49158dd6f2a6481e53359142cc366dff04821a2f/multidict-6.9.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5", size = 279240, upload-time = "2026-09-21T17:54:35.025Z" }, + { url = "https://files.pythonhosted.org/packages/3d/db/0922594c601a691eb4365a83f4f92104ef87a9728d8066fa25c99349e754/multidict-6.9.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4", size = 305487, upload-time = "2026-09-21T17:54:36.555Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/07bca86d4a361a87128f3019540d7689800fdcad44e3b3ad08f1f035ed74/multidict-6.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c", size = 301997, upload-time = "2026-09-21T17:54:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/99591bf626613ea35779d2460949b45e59e589dc9058d6693c923eccd4e5/multidict-6.9.1-cp310-cp310-win32.whl", hash = "sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9", size = 51205, upload-time = "2026-09-21T17:54:39.152Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/10c008aac77297aa618631e8726ece31668f9dc9d9c848e4b5d2e68eb3d3/multidict-6.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0", size = 59049, upload-time = "2026-09-21T17:54:40.465Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/da73a58bef2613571902a824f1e11185207ba756215c99038f6d220bc167/multidict-6.9.1-cp310-cp310-win_arm64.whl", hash = "sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2", size = 54610, upload-time = "2026-09-21T17:54:41.605Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] @@ -3906,22 +3951,54 @@ wheels = [ [[package]] name = "pydantic-ai-slim" -version = "2.50.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "anyio" }, + { name = "exceptiongroup" }, + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx2" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph", version = "2.54.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/c0/b5e33b82b05c87a03e2c18089e89671da5c8fb0ba0da143011917d102745/pydantic_ai_slim-2.54.0.tar.gz", hash = "sha256:e39fd5ded83c5f2f1b271f42707de04fba420c4fd2c3bbc809d8d57614b904df", size = 1802066, upload-time = "2026-10-03T03:38:51.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/2c/93dbb3ba7d13eea6b1c4af02da6a3ab84ed087747b7e801ab675523b3e59/pydantic_ai_slim-2.54.0-py3-none-any.whl", hash = "sha256:b64d2394f1b38d8b02c9a7916ce7bd331cfc25df41372e68d847fd71801fe112", size = 2115426, upload-time = "2026-10-03T03:38:39.601Z" }, +] + +[[package]] +name = "pydantic-ai-slim" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] dependencies = [ { name = "anyio" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "genai-prices" }, { name = "griffelib" }, { name = "httpx2" }, { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-graph" }, + { name = "pydantic-graph", version = "2.55.0", source = { registry = "https://pypi.org/simple" } }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9a/a5a37121adfc6771aefc0d84a41b8fec690ee0951403925cb7cff9452c21/pydantic_ai_slim-2.50.0.tar.gz", hash = "sha256:aa1ab6bfca4590e09e70c9147c4712bdc3e435a6e38d63a4c84a30254b80965f", size = 1630594, upload-time = "2026-09-25T05:03:32.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/3526056afb236ac3c01b07ae05f1fefc97cbee59f8e824b96ffd5405bbfe/pydantic_ai_slim-2.55.0.tar.gz", hash = "sha256:50b5ee70d3a0509c1f36bec759249e69f4dc060f1a816b1bbf0730af54297376", size = 1878726, upload-time = "2026-10-09T19:40:52.951Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/91/a91b43280c24f2fb014eed24ce3ac305d87b00721247741109a48803d569/pydantic_ai_slim-2.50.0-py3-none-any.whl", hash = "sha256:50bd9a355d31698f191b50070f5ab9c57faea2a59a5bd67715c1e23aa1d2a3b3", size = 1928462, upload-time = "2026-09-25T05:03:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cc/16c6bc21748e3705a4c1f8e7d71dfc54228dbf142f8506a36689acb1e9c9/pydantic_ai_slim-2.55.0-py3-none-any.whl", hash = "sha256:7fcea628e78c2f247af94504534f0577151496166998d74ec145505040b19c68", size = 2197589, upload-time = "2026-10-09T19:40:38.767Z" }, ] [[package]] @@ -4044,17 +4121,45 @@ wheels = [ [[package]] name = "pydantic-graph" -version = "2.50.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "anyio" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/6f/b5cb10c937c8d00a219eb5a07aeb991d0c44d09d5ac31a087cd1ad40dbcd/pydantic_graph-2.54.0.tar.gz", hash = "sha256:948ff7249c28643ff028b47fab22b8a5b36912c59bb947c2a9b9bc18de1d5fa5", size = 45970, upload-time = "2026-10-03T03:38:55.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/31/0d46d4003cb9e452bcf97f56f7c948006b5ad8036fd2ddb6fd7d5c73e8ff/pydantic_graph-2.54.0-py3-none-any.whl", hash = "sha256:465b87d5507af1341ba31b949af8619009c5010068159e52cf9b9806eb674fa4", size = 53423, upload-time = "2026-10-03T03:38:45.385Z" }, +] + +[[package]] +name = "pydantic-graph" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] dependencies = [ { name = "anyio" }, { name = "logfire-api" }, { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/bf/230e4c04be14ed0f9c677210e375e4c65e9dac35781601cacdbde7f9fcce/pydantic_graph-2.50.0.tar.gz", hash = "sha256:15a4ab9594baec4c6ce74e7af1b7742f8e546d79c9896a4fea18c1bd83b2b536", size = 45254, upload-time = "2026-09-25T05:03:35.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/22/4e25465059448c4322e5f53f33e7b25ceb404c2d4dda8cfb0f497f7fb54b/pydantic_graph-2.55.0.tar.gz", hash = "sha256:461de7d0c33af634c3adeb21712f527f15878edf826d216184f024795e9c80c1", size = 45824, upload-time = "2026-10-09T19:40:57.238Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/3e/4ae59d3988f342bb8dd4a1f23b7add85cce879dffb75c1ab5ef4be35fc55/pydantic_graph-2.50.0-py3-none-any.whl", hash = "sha256:e43d9742a9924e1e5344fe3cf81a7f8c55d7a524fbc83bec523f284d0f1a31e5", size = 52763, upload-time = "2026-09-25T05:03:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/59/b5/630fc830897cfb0e458dea8ffcac5dd58791784220255637e99817b04cdd/pydantic_graph-2.55.0-py3-none-any.whl", hash = "sha256:774a64db707e759e22a4b2d6f8849986331fac009bb3f36ba3ed0cb8eee7a150", size = 53274, upload-time = "2026-10-09T19:40:44.876Z" }, ] [[package]] diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 931b15380..e4d40e44b 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -2250,9 +2250,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "peer": true, From df1ee96eed1f844c3388c8bc7b3c9c1a4304e0b3 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:40:45 +0000 Subject: [PATCH 05/14] docs(otel): clarify metrics acknowledgement in paired server --- docs/agenteye/opentelemetry.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/agenteye/opentelemetry.mdx b/docs/agenteye/opentelemetry.mdx index adf5237e9..d355f6d00 100644 --- a/docs/agenteye/opentelemetry.mdx +++ b/docs/agenteye/opentelemetry.mdx @@ -42,6 +42,8 @@ Replace `` with your API key. Keep the secret out of shell history, screen Cloud accepts OTLP/HTTP exports for traces, logs, and metrics, as protobuf or JSON, including gzip-compressed requests. It does not accept OTLP/gRPC. +Metrics are acknowledged for exporter compatibility but are not stored or displayed. Supported agent traces and coding-agent logs become sessions; fields the agent does not export remain absent. + ## Capture prompt text From 2e591b2d5b51b3213ac1332e7d6a11f63573ff17 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:47:56 +0000 Subject: [PATCH 06/14] fix(otel): preserve agent settings and require real Copilot installation --- __tests__/hooks/otel.test.ts | 61 ++++++++++++++++++++++++++++++++++-- bun.lock | 2 +- package.json | 2 +- src/hooks/otel-cli.ts | 20 +++++++++--- src/hooks/otel-writers.ts | 45 ++++++++++++++++++++------ 5 files changed, 111 insertions(+), 19 deletions(-) diff --git a/__tests__/hooks/otel.test.ts b/__tests__/hooks/otel.test.ts index b13c2d468..32593dbb8 100644 --- a/__tests__/hooks/otel.test.ts +++ b/__tests__/hooks/otel.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { spawnSync } from "node:child_process"; import { runOtelCommand, chooseWizardOtel, OTEL_ORG_OFF, OTEL_WIZARD_QUESTION } from "../../src/hooks/otel-cli"; -import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, agentOtelWrites } from "../../src/hooks/otel-writers"; +import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, vscodeInstalled, agentOtelWrites } from "../../src/hooks/otel-writers"; import { configFile, credentialsFile, otelAgentStateFile, otelAgentStateDir, otlpSpoolDir, resettablePaths } from "../../src/hooks/fp-home"; import { readConfig, updateConfig } from "../../src/hooks/fp-config"; import { agentSessionsEnabled } from "../../src/hooks/collector-config"; @@ -66,6 +66,36 @@ describe("managed OTEL settings", () => { expect(readConfig().collector.otlp?.enabled).toBe(false); }); + it("local Claude writes only documented content flags and never writes auth header variables", async () => { + const path = agentConfigPath("claude", home); + const before = '{"env":{"KEEP":"yes"}}\n'; + seed(path, before); + await runOtelCommand(["enable", "claude", "--local"], options()); + const env = JSON.parse(readFileSync(path, "utf8")).env; + expect(env.OTEL_LOG_ASSISTANT_RESPONSES).toBeUndefined(); + for (const key of ["OTEL_LOG_USER_PROMPTS", "OTEL_LOG_TOOL_DETAILS", "OTEL_LOG_TOOL_CONTENT"]) expect(env[key]).toBe("1"); + expect(Object.keys(env).some(key => key.endsWith("_HEADERS"))).toBe(false); + expect(agentOtelWrites("claude", "http://127.0.0.1:4318", "key", true, true) + .some(write => write.path.some(key => key.endsWith("_HEADERS")))).toBe(false); + await runOtelCommand(["disable", "claude"], options()); + expect(readFileSync(path, "utf8")).toBe(before); + }); + + it("Cloud-to-local Claude restores prior headers and stops owning them", async () => { + const path = agentConfigPath("claude", home); + const before = '{"env":{"OTEL_EXPORTER_OTLP_HEADERS":"Authorization=prior","OTEL_EXPORTER_OTLP_LOGS_HEADERS":"custom=prior"}}\n'; + seed(path, before); + await runOtelCommand(["enable", "claude"], options()); + await runOtelCommand(["enable", "claude", "--local"], options()); + expect(JSON.parse(readFileSync(path, "utf8")).env).toMatchObject({ + OTEL_EXPORTER_OTLP_HEADERS: "Authorization=prior", OTEL_EXPORTER_OTLP_LOGS_HEADERS: "custom=prior", + }); + const record = JSON.parse(readFileSync(configFile(), "utf8")).otel.agents.claude; + expect(record.keys.some((key: { path: string[] }) => key.path.some(p => p.endsWith("_HEADERS")))).toBe(false); + await runOtelCommand(["disable", "claude"], options()); + expect(readFileSync(path, "utf8")).toBe(before); + }); + it("restores only owned keys after unrelated Claude changes", async () => { const path = agentConfigPath("claude", home); seed(path, '{"env":{"KEEP":"x","OTEL_LOG_USER_PROMPTS":"old"},"theme":"old"}'); @@ -104,6 +134,7 @@ describe("managed OTEL settings", () => { expect(result.exitCode, result.lines.join("\n")).toBe(0); const next = readFileSync(path, "utf8"); expect(next.startsWith(prefix + "# OTEL\n")).toBe(true); + expect(next).toContain("[otel] # existing\n"); expect(next.endsWith(suffix)).toBe(true); const parsed = parseAgentConfig(next, "codex"); expect(parsed.otel).toMatchObject({ @@ -129,6 +160,7 @@ describe("managed OTEL settings", () => { const path = agentConfigPath(agent, home); const before = '{\n// user comment\n"editor.fontSize": 13,\n"telemetry": {"unknown": 42},\n}\n'; seed(path, before); + if (agent === "copilot") mkdirSync(join(home, ".vscode/extensions/github.copilot-chat-0.55.0"), { recursive: true }); const result = await runOtelCommand(["enable", agent, "--no-content"], options()); expect(result.exitCode).toBe(0); expect(result.lines.join("\n")).toContain("automatically"); @@ -144,7 +176,15 @@ describe("managed OTEL settings", () => { }); it("skips VS Code when absent and resolves macOS, Linux and Windows user paths", async () => { - expect((await runOtelCommand(["enable", "copilot"], options())).lines.join("\n")).toContain("not installed"); + const savedPath = process.env.PATH; + process.env.PATH = ""; + try { + mkdirSync(dirname(agentConfigPath("copilot", home)), { recursive: true }); + const before = readFileSync(configFile(), "utf8"); + expect((await runOtelCommand(["enable", "copilot"], options())).lines).toEqual(["VS Code not found — skipping copilot"]); + expect(readFileSync(configFile(), "utf8")).toBe(before); + expect(vscodeInstalled(home)).toBe(false); + } finally { process.env.PATH = savedPath; } expect(existsSync(agentConfigPath("copilot", home))).toBe(false); expect(vscodeSettingsPath(home, "darwin")).toContain("Library/Application Support/Code/User/settings.json"); expect(vscodeSettingsPath(home, "linux")).toContain(".config/Code/User/settings.json"); @@ -154,6 +194,23 @@ describe("managed OTEL settings", () => { if (before === undefined) delete process.env.APPDATA; else process.env.APPDATA = before; }); + it("detects an executable or Copilot extension, not an unrelated extension", () => { + const savedPath = process.env.PATH; + process.env.PATH = ""; + try { + mkdirSync(join(home, ".vscode/extensions/unrelated.extension-1.0"), { recursive: true }); + expect(vscodeInstalled(home)).toBe(false); + mkdirSync(join(home, ".vscode/extensions/github.copilot-1.0"), { recursive: true }); + expect(vscodeInstalled(home)).toBe(true); + rmSync(join(home, ".vscode"), { recursive: true }); + const bin = join(home, "bin"); + mkdirSync(bin); + writeFileSync(join(bin, "code-insiders"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = bin; + expect(vscodeInstalled(home)).toBe(true); + } finally { process.env.PATH = savedPath; } + }); + it("never overwrites an invalid config or creates a backup for it", async () => { const path = agentConfigPath("claude", home); seed(path, "{ invalid json"); diff --git a/bun.lock b/bun.lock index ccf6a2cf9..ad51b469e 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "eslint-config-next": "^16.3.2", "jsdom": "^30.0.1", "lucide-react": "^1.33.0", - "next": "16.3.8", + "next": "^16.3.8", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.4.0", diff --git a/package.json b/package.json index b36778204..18cb4e938 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "eslint-config-next": "^16.3.2", "jsdom": "^30.0.1", "lucide-react": "^1.33.0", - "next": "16.3.8", + "next": "^16.3.8", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.4.0", diff --git a/src/hooks/otel-cli.ts b/src/hooks/otel-cli.ts index 38441d100..53f1903dc 100644 --- a/src/hooks/otel-cli.ts +++ b/src/hooks/otel-cli.ts @@ -154,8 +154,8 @@ async function enableAgent(agent: OtelAgent, yes: boolean, localFlag: boolean, c const raw = rawConfig(); const previousConfig = structuredClone(raw); const file = agentConfigPath(agent, options.home ?? homedir()); - if (agent === "copilot" && !vscodeInstalled(file)) { - return { lines: ["VS Code is not installed (no user settings directory); skipping Copilot."], exitCode: 0 }; + if (agent === "copilot" && !vscodeInstalled(options.home ?? homedir())) { + return { lines: ["VS Code not found — skipping copilot"], exitCode: 0 }; } const existing = records(raw)[agent]; // Gemini has no transcript source in this collector. @@ -171,9 +171,14 @@ async function enableAgent(agent: OtelAgent, yes: boolean, localFlag: boolean, c const original = existsSync(file) ? readFileSync(file, "utf8") : null; const parsed = parseAgentConfig(original ?? "", agent); const writes = agentOtelWrites(agent, destination, key, local, content); - const next = editAgentConfig(original ?? "", agent, writes); + // Switching from Cloud to local stops owning header keys. Restore any keys + // from an earlier enable before applying the new (header-free) write set. + const oldState = existing ? privateState(agent) : null; + const released = oldState?.previous.filter(previous => + !writes.some(write => JSON.stringify(write.path) === JSON.stringify(previous.path))) ?? []; + const next = editAgentConfig(original ?? "", agent, [...released, ...writes]); // On repeated enable keep the ORIGINAL previous values, not our last write. - const state = existing ? privateState(agent) : { + const state = oldState ?? { original, originalMode: original === null ? undefined : statSync(file).mode & 0o777, writtenHash: "", @@ -184,6 +189,11 @@ async function enableAgent(agent: OtelAgent, yes: boolean, localFlag: boolean, c // discard changes made after the previous enable. state.original = editAgentConfig(original, agent, state.previous); } + if (existing) { + state.previous = writes.map(write => state.previous.find(previous => + JSON.stringify(previous.path) === JSON.stringify(write.path)) ?? + { path: write.path, value: getSetting(parsed, write.path) }); + } const collector = object(raw.collector); const agents = object(collector.agents); const previousSessions = object(agents[agent]).sessions as boolean | undefined; @@ -304,7 +314,7 @@ export async function runOtelCommand(args: string[], options: OtelOptions = {}): const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; const values = { OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", - OTEL_EXPORTER_OTLP_HEADERS: local ? "" : `Authorization=Bearer%20${encodeURIComponent(key)}`, + ...(!local ? { OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer%20${encodeURIComponent(key)}` } : {}), OTEL_SERVICE_NAME: service, }; return { lines: Object.entries(values).map(([k, v]) => `export ${k}=${quote(v)}`), exitCode: 0 }; diff --git a/src/hooks/otel-writers.ts b/src/hooks/otel-writers.ts index ed872b759..6f2962335 100644 --- a/src/hooks/otel-writers.ts +++ b/src/hooks/otel-writers.ts @@ -7,7 +7,7 @@ import { parse as parseToml } from "smol-toml"; import { applyEdits, modify, parse as parseJsonc, type ParseError } from "jsonc-parser/lib/esm/main.js"; import { homedir } from "node:os"; import { resolve } from "node:path"; -import { existsSync } from "node:fs"; +import { accessSync, constants, readdirSync, statSync } from "node:fs"; export const OTEL_AGENTS = ["claude", "codex", "gemini", "copilot"] as const; export type OtelAgent = typeof OTEL_AGENTS[number]; @@ -28,8 +28,31 @@ export function agentConfigPath(agent: OtelAgent, home = homedir()): string { resolve(home, `.${agent}`, agent === "codex" ? "config.toml" : "settings.json"); } -export function vscodeInstalled(path: string): boolean { - return existsSync(resolve(path, "..")); +export function vscodeInstalled(home = homedir(), platform = process.platform): boolean { + const executable = (path: string) => { + try { + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return statSync(path).isFile(); + } catch { return false; } + }; + const names = platform === "win32" ? ["code.cmd", "code-insiders.cmd", "Code.exe", "Code - Insiders.exe"] : ["code", "code-insiders"]; + if ((process.env.PATH ?? "").split(platform === "win32" ? ";" : ":") + .some(dir => names.some(name => executable(resolve(dir, name))))) return true; + const apps = platform === "darwin" ? [ + "/Applications/Visual Studio Code.app/Contents/MacOS/Electron", + "/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Electron", + resolve(home, "Applications/Visual Studio Code.app/Contents/MacOS/Electron"), + ] : platform === "win32" ? [ + resolve(process.env.LOCALAPPDATA ?? resolve(home, "AppData/Local"), "Programs/Microsoft VS Code/Code.exe"), + resolve(process.env.ProgramFiles ?? "C:/Program Files", "Microsoft VS Code/Code.exe"), + ] : ["/usr/share/code/code", "/opt/visual-studio-code/code"]; + if (apps.some(executable)) return true; + return [".vscode", ".vscode-insiders"].some(folder => { + try { + return readdirSync(resolve(home, folder, "extensions"), { withFileTypes: true }) + .some(entry => entry.isDirectory() && /^github\.copilot(?:-chat)?-\d/i.test(entry.name)); + } catch { return false; } + }); } export function parseAgentConfig(text: string, agent: OtelAgent): JsonObject { @@ -78,8 +101,8 @@ function tomlValue(value: unknown): string { throw new Error("Unsupported TOML value."); } -function tables(text: string): { start: number; otel: boolean }[] { - const result: { start: number; otel: boolean }[] = []; +function tables(text: string): { start: number; otel: boolean; rootHeader?: string }[] { + const result: { start: number; otel: boolean; rootHeader?: string }[] = []; let multiline: string | null = null; let offset = 0; for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) { @@ -87,7 +110,9 @@ function tables(text: string): { start: number; otel: boolean }[] { const header = /^\s*(\[\[?.*?\]\]?)\s*(?:#.*)?(?:\r?\n)?$/.exec(line); if (header) { const sample = parseToml(`${header[1]}\n__failproofai_probe = true\n`); - result.push({ start: offset, otel: Object.hasOwn(sample, "otel") }); + const root = Object.keys(sample).length === 1 && Object.hasOwn(sample, "otel") && + Object.hasOwn(sample.otel as object, "__failproofai_probe"); + result.push({ start: offset, otel: Object.hasOwn(sample, "otel"), rootHeader: root ? line.replace(/\r?\n$/, "") : undefined }); } } let quote: string | null = null; @@ -133,7 +158,8 @@ export function editAgentConfig(text: string, agent: OtelAgent, writes: SettingW } const newline = text.includes("\r\n") ? "\r\n" : "\n"; const otel = parsed.otel as JsonObject | undefined; - const replacement = otel ? `[otel]${newline}${Object.entries(otel).map(([k, v]) => `${JSON.stringify(k)} = ${tomlValue(v)}${newline}`).join("")}` : ""; + const rootHeader = headers.find(h => h.rootHeader)?.rootHeader ?? "[otel]"; + const replacement = otel ? `${rootHeader}${newline}${Object.entries(otel).map(([k, v]) => `${JSON.stringify(k)} = ${tomlValue(v)}${newline}`).join("")}` : ""; let next = text; const ranges = headers.map((h, i) => { const end = headers[i + 1]?.start ?? text.length; @@ -156,17 +182,16 @@ export function agentOtelWrites(agent: OtelAgent, endpoint: string, key: string, CLAUDE_CODE_ENABLE_TELEMETRY: "1", OTEL_LOGS_EXPORTER: "otlp", OTEL_METRICS_EXPORTER: "otlp", OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", - OTEL_EXPORTER_OTLP_HEADERS: local ? "" : `Authorization=Bearer%20${encodeURIComponent(key)}`, + ...(!local ? { OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer%20${encodeURIComponent(key)}` } : {}), OTEL_LOG_USER_PROMPTS: content ? "1" : "0", OTEL_LOG_TOOL_DETAILS: content ? "1" : "0", - OTEL_LOG_ASSISTANT_RESPONSES: content ? "1" : "0", OTEL_LOG_TOOL_CONTENT: content ? "1" : "0", }; if (agent === "claude") { for (const signal of ["LOGS", "METRICS"]) { env[`OTEL_EXPORTER_OTLP_${signal}_ENDPOINT`] = `${endpoint}/v1/${signal.toLowerCase()}`; env[`OTEL_EXPORTER_OTLP_${signal}_PROTOCOL`] = "http/protobuf"; - env[`OTEL_EXPORTER_OTLP_${signal}_HEADERS`] = env.OTEL_EXPORTER_OTLP_HEADERS; + if (!local) env[`OTEL_EXPORTER_OTLP_${signal}_HEADERS`] = env.OTEL_EXPORTER_OTLP_HEADERS; } return Object.entries(env).map(([k, value]) => ({ path: ["env", k], value })); } From d6476361781a0f702c1961a2e0bb97186c0efb95 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:47:56 +0000 Subject: [PATCH 07/14] feat(otel): add optional durable TypeScript and Python span exporters --- CHANGELOG.md | 5 + crates/failproofaid/tests/otlp_relay_e2e.rs | 49 +++++ crates/fpai-collect/src/otlp.rs | 20 +- docs/reference/custom-agents-typescript.mdx | 32 +++ docs/reference/custom-agents.mdx | 29 +++ docs/reference/otel.mdx | 2 +- sdk/python/README.md | 41 ++++ sdk/python/docs/README.md | 1 + sdk/python/docs/otel/README.md | 36 ++++ sdk/python/docs/otel/examples/agent_trace.py | 30 +++ sdk/python/docs/otel/examples/quickstart.py | 22 ++ sdk/python/failproofai_sdk/__init__.py | 8 + .../failproofai_sdk/integrations/__init__.py | 3 + .../failproofai_sdk/integrations/otel.py | 189 ++++++++++++++++ sdk/python/pyproject.toml | 3 +- sdk/python/tests/test_docs.py | 19 +- sdk/python/tests/test_integrations.py | 2 +- sdk/python/tests/test_otel.py | 118 ++++++++++ sdk/python/tests/test_zero_dependencies.py | 5 +- sdk/python/uv.lock | 8 +- sdk/typescript/README.md | 43 ++++ sdk/typescript/package-lock.json | 52 +++++ sdk/typescript/package.json | 2 + sdk/typescript/src/edge/index.ts | 9 + sdk/typescript/src/index.ts | 2 + sdk/typescript/src/integrations/index.ts | 5 +- sdk/typescript/src/integrations/otel.ts | 204 ++++++++++++++++++ sdk/typescript/test/integrations.test.ts | 2 +- sdk/typescript/test/otel.test.ts | 125 +++++++++++ 29 files changed, 1047 insertions(+), 19 deletions(-) create mode 100644 sdk/python/docs/otel/README.md create mode 100644 sdk/python/docs/otel/examples/agent_trace.py create mode 100644 sdk/python/docs/otel/examples/quickstart.py create mode 100644 sdk/python/failproofai_sdk/integrations/otel.py create mode 100644 sdk/python/tests/test_otel.py create mode 100644 sdk/typescript/src/integrations/otel.ts create mode 100644 sdk/typescript/test/otel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d77b78c4..b887273d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Features - Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (#880). +- Add optional TypeScript and Python OpenTelemetry span exporters with durable OTLP/JSON spool delivery and no hard runtime dependencies (#880). + +### Fixes + +- Preserve Codex OTEL header comments, omit local Claude auth-header settings, and require an executable or Copilot extension before configuring VS Code (#880). ### Dependencies diff --git a/crates/failproofaid/tests/otlp_relay_e2e.rs b/crates/failproofaid/tests/otlp_relay_e2e.rs index 3cc43774e..076fbaf06 100644 --- a/crates/failproofaid/tests/otlp_relay_e2e.rs +++ b/crates/failproofaid/tests/otlp_relay_e2e.rs @@ -146,3 +146,52 @@ fn relay_starts_and_stops_on_config_reload_without_daemon_restart() { wait(|| TcpStream::connect(("127.0.0.1", port)).is_err()); ping(&daemon); } + +#[test] +fn sdk_spool_delivers_without_opening_a_tcp_listener() { + let capture = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let origin_port = capture.local_addr().unwrap().port(); + let reserved = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let relay_port = reserved.local_addr().unwrap().port(); + drop(reserved); + let daemon = start(relay_port, false); + std::fs::write( + daemon.home.join("credentials.json"), + format!( + r#"{{"ingest":{{"url":"http://127.0.0.1:{origin_port}/v1/events","key":"test"}}}}"# + ), + ) + .unwrap(); + let spool = daemon.home.join("state/spool-otlp"); + std::fs::create_dir_all(&spool).unwrap(); + // Exact shared SDK format: metadata line followed by ExportTraceServiceRequest JSON. + let batch = spool.join("otlp-sdk-contract.jsonl"); + std::fs::write(&batch, b"{\"path\":\"/v1/traces\",\"content_type\":\"application/json\",\"encoding\":null}\n{\"resourceSpans\":[]}").unwrap(); + capture.set_nonblocking(true).unwrap(); + let start = Instant::now(); + let mut stream = loop { + if let Ok((stream, _)) = capture.accept() { + break stream; + } + assert!( + start.elapsed() < Duration::from_secs(20), + "SDK batch never reached capture server" + ); + std::thread::sleep(Duration::from_millis(50)); + }; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_lowercase(); + assert!(request.starts_with("post /v1/traces ")); + assert!(request.contains("authorization: bearer test")); + assert!(request.contains("content-type: application/json")); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .unwrap(); + wait(|| !batch.exists()); + assert!(TcpStream::connect(("127.0.0.1", relay_port)).is_err()); + ping(&daemon); +} diff --git a/crates/fpai-collect/src/otlp.rs b/crates/fpai-collect/src/otlp.rs index 9b24b0578..7309debe5 100644 --- a/crates/fpai-collect/src/otlp.rs +++ b/crates/fpai-collect/src/otlp.rs @@ -430,9 +430,6 @@ async fn deliver_loop(relay: Arc, sd: Shutdown) -> Result<(), TaskError> } pub fn tasks(home: PathBuf, ingest: Ingest, settings: &OtlpSettings) -> Vec { - if !settings.enabled { - return Vec::new(); - } let relay = match Relay::new(&home, &ingest) { Ok(relay) => Arc::new(relay), Err(err) => { @@ -442,12 +439,17 @@ pub fn tasks(home: PathBuf, ingest: Ingest, settings: &OtlpSettings) -> Vec` binds the *module* onto this # package as `failproofai_sdk._writer`. Rebinding it here to the instance is what # keeps the published `failproofai_sdk._writer.flush_now()` recipe working — and diff --git a/sdk/python/failproofai_sdk/integrations/__init__.py b/sdk/python/failproofai_sdk/integrations/__init__.py index 4e60d8262..1bf89528c 100644 --- a/sdk/python/failproofai_sdk/integrations/__init__.py +++ b/sdk/python/failproofai_sdk/integrations/__init__.py @@ -58,6 +58,7 @@ def uninstall() -> None "crewai": "failproofai_sdk.integrations.crewai", "llama_index": "failproofai_sdk.integrations.llama_index", "pydantic_ai": "failproofai_sdk.integrations.pydantic_ai", + "otel": "failproofai_sdk.integrations.otel", } # Spellings people actually type. LangGraph is served by the LangChain adapter @@ -79,6 +80,8 @@ def uninstall() -> None "crewai": ("crewai",), "llama_index": ("llama_index", "llama_index.core"), "pydantic_ai": ("pydantic_ai",), + # OTEL is explicit-only: installing it must not duplicate existing adapters. + "otel": (), } # Guards _ACTIVE and every install/uninstall. `instrument()` is called from diff --git a/sdk/python/failproofai_sdk/integrations/otel.py b/sdk/python/failproofai_sdk/integrations/otel.py new file mode 100644 index 000000000..bce31b141 --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/otel.py @@ -0,0 +1,189 @@ +"""Explicit OTLP/JSON exporter; disk only. OpenTelemetry is an optional runtime import.""" +import base64 +import json +import logging +import math +import os +import threading +import uuid +from pathlib import Path + +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult, BatchSpanProcessor + +_MAX_BYTES = 8 * 1024 * 1024 +_METADATA = b'{"path":"/v1/traces","content_type":"application/json","encoding":null}\n' + + +def _value(value): + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, str): + return {"stringValue": value} + if isinstance(value, int): + return {"intValue": str(value)} + if isinstance(value, float): + return {"doubleValue": value if math.isfinite(value) else str(value)} + if isinstance(value, bytes): + return {"bytesValue": base64.b64encode(value).decode("ascii")} + if isinstance(value, (list, tuple)): + return {"arrayValue": {"values": [_value(v) for v in value]}} + if hasattr(value, "items"): + return {"kvlistValue": {"values": _attributes(value)}} + return {} + + +def _attributes(attributes=None): + return [{"key": str(k), "value": _value(v)} for k, v in (attributes or {}).items() if v is not None] + + +def _context(context): + result = {"traceId": f"{context.trace_id:032x}", "spanId": f"{context.span_id:016x}", + "flags": int(context.trace_flags)} + if context.trace_state: + result["traceState"] = context.trace_state.to_header() + return result + + +def _resource_span(span): + data = _context(span.context) + if span.parent: + data["parentSpanId"] = f"{span.parent.span_id:016x}" + data.update({ + "name": span.name, "kind": span.kind.value + 1, + "startTimeUnixNano": str(span.start_time), "endTimeUnixNano": str(span.end_time), + "attributes": _attributes(span.attributes), + "status": {"code": span.status.status_code.value, **({"message": span.status.description} if span.status.description else {})}, + "droppedAttributesCount": span.dropped_attributes, + "droppedEventsCount": span.dropped_events, "droppedLinksCount": span.dropped_links, + "events": [{"name": event.name, "timeUnixNano": str(event.timestamp), + "attributes": _attributes(event.attributes), + "droppedAttributesCount": event.dropped_attributes} for event in span.events], + "links": [{**_context(link.context), "attributes": _attributes(link.attributes), + "droppedAttributesCount": link.dropped_attributes} for link in span.links], + }) + scope = span.instrumentation_scope + return { + "resource": {"attributes": _attributes(span.resource.attributes)}, + **({"schemaUrl": span.resource.schema_url} if span.resource.schema_url else {}), + "scopeSpans": [{ + "scope": {"name": scope.name if scope else "", **({"version": scope.version} if scope and scope.version else {})}, + **({"schemaUrl": scope.schema_url} if scope and scope.schema_url else {}), + "spans": [data], + }], + } + + +class OtelSpanExporter(SpanExporter): + """Exports standard spans durably to the daemon's raw OTLP spool, never HTTP.""" + + def __init__(self, *, spool_dir=None): + self._directory = Path(spool_dir) if spool_dir is not None else None + self._stopped = False + self._lock = threading.Lock() + + def export(self, spans): + with self._lock: + if self._stopped: + return SpanExportResult.FAILURE + try: + directory = self._directory or Path(os.environ.get("FAILPROOFAI_HOME", Path.home() / ".failproofai")) / "state" / "spool-otlp" + items, chunks, size = [], [], 20 + for span in spans: + encoded = json.dumps(_resource_span(span), ensure_ascii=True, separators=(",", ":")).encode("utf8") + if len(encoded) + 20 > _MAX_BYTES: + raise ValueError("OTEL span exceeds the 8 MiB relay limit") + if size + len(encoded) + 1 > _MAX_BYTES: + chunks.append(b'{"resourceSpans":[' + b",".join(items) + b"]}") + items, size = [], 20 + items.append(encoded) + size += len(encoded) + 1 + if items: + chunks.append(b'{"resourceSpans":[' + b",".join(items) + b"]}") + for chunk in chunks: + self._publish(directory, chunk) + return SpanExportResult.SUCCESS + except Exception: + logging.getLogger(__name__).warning("OTEL export failed; request was not published", exc_info=True) + return SpanExportResult.FAILURE + + def _publish(self, directory, body): + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + name = f"otlp-sdk-{uuid.uuid4()}" + temporary = directory / f".{name}.tmp" + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(_METADATA + body) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, directory / f"{name}.jsonl") + if os.name != "nt": + descriptor = os.open(directory, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + temporary.unlink(missing_ok=True) + + def shutdown(self): + with self._lock: + self._stopped = True + + def force_flush(self, timeout_millis=30000): + return True + + +class _Gate: + def __init__(self, processor): + self.processor = processor + self.enabled = True + + def on_start(self, span, parent_context=None): + if self.enabled: + self.processor.on_start(span, parent_context=parent_context) + + def on_end(self, span): + if self.enabled: + self.processor.on_end(span) + + def _on_ending(self, span): + if self.enabled: + callback = getattr(self.processor, "_on_ending", None) + if callback: + callback(span) + + def shutdown(self): + self.processor.shutdown() + + def force_flush(self, timeout_millis=30000): + return self.processor.force_flush(timeout_millis) if self.enabled else True + + +class _Adapter: + name = "otel" + module = "opentelemetry" + + def __init__(self): + self.gate = None + + def install(self, **options): + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + provider = options.get("provider") or trace.get_tracer_provider() + if not hasattr(provider, "add_span_processor"): + if options.get("provider") is not None: + raise ValueError("OTEL provider must support add_span_processor()") + provider = TracerProvider() + trace.set_tracer_provider(provider) + self.gate = _Gate(BatchSpanProcessor(OtelSpanExporter(spool_dir=options.get("spool_dir")))) + provider.add_span_processor(self.gate) + + def uninstall(self): + if self.gate: + self.gate.enabled = False + self.gate.shutdown() + self.gate = None + + +adapter = _Adapter() diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 9ad8c32c4..e107419ac 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -50,7 +50,7 @@ Changelog = "https://github.com/FailproofAI/failproofai/blob/main/sdk/python/CHA # `tomli` is `tomllib`'s backport: 3.10 has no stdlib TOML parser, and that test # reads this file to prove the promise, so on 3.10 it needs one rather than # skipping the check on the oldest interpreter we advertise. -dev = ["pytest>=7", "tomli>=2; python_version<'3.11'", "pytest-asyncio>=1.3,<2"] +dev = ["pytest>=7", "tomli>=2; python_version<'3.11'", "pytest-asyncio>=1.3,<2", "opentelemetry-sdk>=1.39,<2"] # Convenience aliases that pull the FRAMEWORK in. The adapter code always ships # in the base wheel and lazy-imports, so these gate nothing on our side — most @@ -72,6 +72,7 @@ crewai = [ ] llamaindex = ["llama-index-core>=0.14.23,<0.15"] pydantic-ai = ["pydantic-ai-slim>=2.0,<3"] # 2.0 removed Agent(instrument=...) +otel = ["opentelemetry-sdk>=1.39,<2"] # optional SpanExporter / processor APIs [tool.setuptools.dynamic] version = { attr = "failproofai_sdk._version.__version__" } diff --git a/sdk/python/tests/test_docs.py b/sdk/python/tests/test_docs.py index 4fbd936a8..31fb45eef 100644 --- a/sdk/python/tests/test_docs.py +++ b/sdk/python/tests/test_docs.py @@ -40,6 +40,7 @@ "crewai": "crewai", "llama_index": "llama_index", "pydantic_ai": "pydantic_ai", + "otel": "otel", } #: directory -> the failproofai-sdk extra its guide must name. @@ -48,6 +49,7 @@ "crewai": "crewai", "llama_index": "llamaindex", "pydantic_ai": "pydantic-ai", + "otel": "otel", } #: directory -> third-party modules its examples may import. `manual/` maps to @@ -59,6 +61,7 @@ "llama_index": {"llama_index"}, "pydantic_ai": {"pydantic_ai", "pydantic"}, "manual": {"openai"}, + "otel": {"opentelemetry"}, } #: stdlib and local helpers every example may import. `_shared` is the trace @@ -191,7 +194,12 @@ def test_a_framework_guide_names_its_own_extra(directory, extra): def test_a_framework_guide_shows_instrument_and_a_session(directory): text = (DOCS / directory / "README.md").read_text(encoding="utf-8") assert "failproofai_sdk.instrument(" in text, f"{directory} never shows instrument()" - assert "failproofai_sdk.session()" in text, f"{directory} never shows a session" + if directory == "otel": + # OTEL supplies trace identity itself, not the SDK event scopes. + assert 'instrument("otel"' in text + assert "start_as_current_span(" in text + else: + assert "failproofai_sdk.session()" in text, f"{directory} never shows a session" @pytest.mark.parametrize("guide", _guides(), ids=lambda p: p.parent.name) @@ -285,7 +293,11 @@ def test_an_example_does_not_thread_identity_by_hand(path): @pytest.mark.parametrize("path", EXAMPLES, ids=EXAMPLE_IDS) def test_an_example_opens_a_session(path): source = path.read_text(encoding="utf-8") - assert "failproofai_sdk.session()" in source, f"{path.name} never opens a session" + if path.parents[1].name == "otel": + assert "start_as_current_span(" in source + assert '"gen_ai.operation.name": "invoke_agent"' in source + else: + assert "failproofai_sdk.session()" in source, f"{path.name} never opens a session" @pytest.mark.parametrize( @@ -295,7 +307,8 @@ def test_an_example_opens_a_session(path): ) def test_a_framework_example_instruments(path): source = path.read_text(encoding="utf-8") - assert "failproofai_sdk.instrument()" in source, f"{path.name} never instruments" + call = 'failproofai_sdk.instrument("otel",' if path.parents[1].name == "otel" else "failproofai_sdk.instrument()" + assert call in source, f"{path.name} never instruments" @pytest.mark.parametrize( diff --git a/sdk/python/tests/test_integrations.py b/sdk/python/tests/test_integrations.py index 4d2830e07..ffd09e658 100644 --- a/sdk/python/tests/test_integrations.py +++ b/sdk/python/tests/test_integrations.py @@ -106,7 +106,7 @@ def fake(monkeypatch): def test_the_four_adapters_are_pre_registered(): # Registered before their modules exist so that adding an adapter is one new # file, never an edit to the registry (which four agents would conflict on). - assert integrations.available() == ("crewai", "langchain", "llama_index", "pydantic_ai") + assert integrations.available() == ("crewai", "langchain", "llama_index", "otel", "pydantic_ai") @pytest.mark.parametrize( diff --git a/sdk/python/tests/test_otel.py b/sdk/python/tests/test_otel.py new file mode 100644 index 000000000..ac6f38156 --- /dev/null +++ b/sdk/python/tests/test_otel.py @@ -0,0 +1,118 @@ +import json +from pathlib import Path + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult +from opentelemetry.trace import Link, SpanKind, Status, StatusCode + +import failproofai_sdk +from failproofai_sdk.integrations import _core + + +@pytest.fixture(autouse=True) +def enabled_otel(monkeypatch): + # The CrewAI test module disables its own vendor telemetry at collection. + # These tests explicitly exercise OTEL, without changing that other suite. + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + + +def batches(home): + result = [] + for file in (home / "state/spool-otlp").glob("*.jsonl"): + header, body = file.read_bytes().split(b"\n", 1) + assert json.loads(header) == {"path": "/v1/traces", "content_type": "application/json", "encoding": None} + assert file.stat().st_mode & 0o777 == 0o600 + result.extend(json.loads(body)["resourceSpans"]) + return result + + +def test_real_spans_preserve_context_attributes_events_and_precise_time(tmp_path, monkeypatch): + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path)) + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(failproofai_sdk.OtelSpanExporter())) + tracer = provider.get_tracer("scope", "1.0") + parent = tracer.start_span("agent", start_time=1780000000000000123) + child = tracer.start_span("chat", kind=SpanKind.CLIENT, + context=trace.set_span_in_context(parent), + start_time=1780000000000000456, + attributes={"gen_ai.operation.name": "chat", "count": 42, "enabled": True, "values": ("a", "b")}, + links=[Link(parent.get_span_context(), attributes={"linked": True})]) + child.add_event("prompt", {"text": "hi"}, timestamp=1780000000000000789) + child.set_status(Status(StatusCode.ERROR, "failed")) + child.end(end_time=1780000001000000012) + parent.end(end_time=1780000001000000013) + assert provider.force_flush() + output = next(span for resource in batches(tmp_path) for scope in resource["scopeSpans"] + for span in scope["spans"] if span["name"] == "chat") + assert output["traceId"] == f"{child.get_span_context().trace_id:032x}" + assert output["parentSpanId"] == f"{parent.get_span_context().span_id:016x}" + assert output["kind"] == 3 + assert output["startTimeUnixNano"] == "1780000000000000456" + assert output["endTimeUnixNano"] == "1780000001000000012" + assert output["status"] == {"code": 2, "message": "failed"} + assert {"key": "count", "value": {"intValue": "42"}} in output["attributes"] + assert output["events"][0]["timeUnixNano"] == "1780000000000000789" + assert output["links"][0]["spanId"] == f"{parent.get_span_context().span_id:016x}" + assert not list((tmp_path / "state/spool-otlp").glob("*.tmp")) + provider.shutdown() + + +def test_exporter_reports_failures_and_shutdown(tmp_path): + file = tmp_path / "not-a-directory" + file.write_text("x") + exporter = failproofai_sdk.OtelSpanExporter(spool_dir=file) + provider = TracerProvider() + span = provider.get_tracer("app").start_span("test") + span.end() + assert exporter.export([span]) == SpanExportResult.FAILURE + exporter.shutdown() + assert exporter.export([]) == SpanExportResult.FAILURE + assert exporter.force_flush() + provider.shutdown() + + +def test_batches_split_at_relay_limit(tmp_path, monkeypatch): + from opentelemetry.sdk.trace import SpanLimits + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path)) + provider = TracerProvider(span_limits=SpanLimits(max_attribute_length=10_000_000)) + span = provider.get_tracer("app").start_span("large", attributes={"content": "x" * (4 * 1024 * 1024)}) + span.end() + assert failproofai_sdk.OtelSpanExporter().export([span, span]) == SpanExportResult.SUCCESS + assert len(list((tmp_path / "state/spool-otlp").glob("*.jsonl"))) == 2 + assert len(batches(tmp_path)) == 2 + provider.shutdown() + + +def test_instrument_is_explicit_gated_and_keeps_other_processors(tmp_path, monkeypatch): + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path)) + _core.set_strict(True) + provider = TracerProvider() + other = [] + + class Existing: + def on_start(self, *_args, **_kwargs): pass + def on_end(self, span): other.append(span.name) + def shutdown(self): pass + def force_flush(self, *_args): return True + def _on_ending(self, span): pass + + provider.add_span_processor(Existing()) + try: + assert "otel" not in failproofai_sdk.integrations._detected() + assert failproofai_sdk.instrument("otel", provider=provider) == ("otel",) + assert failproofai_sdk.instrument("otel", provider=provider) == () + tracer = provider.get_tracer("app") + tracer.start_span("before").end() + assert provider.force_flush() + assert len(batches(tmp_path)) == 1 + assert failproofai_sdk.uninstrument("otel") == ("otel",) + tracer.start_span("after").end() + assert provider.force_flush() + assert len(batches(tmp_path)) == 1 + assert other == ["before", "after"] + finally: + failproofai_sdk.uninstrument("otel") + _core.set_strict(None) + provider.shutdown() diff --git a/sdk/python/tests/test_zero_dependencies.py b/sdk/python/tests/test_zero_dependencies.py index 95be7b72e..9915684a7 100644 --- a/sdk/python/tests/test_zero_dependencies.py +++ b/sdk/python/tests/test_zero_dependencies.py @@ -61,13 +61,14 @@ "crewai.py": {"crewai"}, "llama_index.py": {"llama_index", "llama_index_instrumentation", "pydantic"}, "pydantic_ai.py": {"pydantic_ai"}, + "otel.py": {"opentelemetry"}, } #: Framework top-level names that must never appear in `sys.modules` after a bare #: `import failproofai_sdk`. FRAMEWORK_ROOTS = { "langchain", "langchain_core", "langgraph", "crewai", - "llama_index", "llama_index_instrumentation", "pydantic", "pydantic_ai", + "llama_index", "llama_index_instrumentation", "pydantic", "pydantic_ai", "opentelemetry", } @@ -142,6 +143,7 @@ def test_no_runtime_dependencies_are_declared(): #: convenience library drifting in — anything here is one `--extra dev` away from a #: user's environment, and none of it is covered by the zero-dependency promise. ALLOWED_DEV_DEPENDENCIES = { + "opentelemetry-sdk": "real optional OTEL SpanExporter and processor contract tests; never a base dependency", "pytest": "the test runner", "pytest-asyncio": "the adapters are half-async; their scopes are exercised " "under `async with`, which needs an async test runner", @@ -159,6 +161,7 @@ def test_no_runtime_dependencies_are_declared(): "crewai": {"crewai", "onnxruntime"}, "llamaindex": {"llama-index-core"}, "pydantic-ai": {"pydantic-ai-slim"}, + "otel": {"opentelemetry-sdk"}, } diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 3f4607c88..9bab04bd7 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -1012,6 +1012,7 @@ crewai = [ { name = "onnxruntime", marker = "python_full_version < '3.11'" }, ] dev = [ + { name = "opentelemetry-sdk" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -1025,6 +1026,9 @@ langgraph = [ llamaindex = [ { name = "llama-index-core" }, ] +otel = [ + { name = "opentelemetry-sdk" }, +] pydantic-ai = [ { name = "pydantic-ai-slim", version = "2.54.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pydantic-ai-slim", version = "2.55.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -1037,12 +1041,14 @@ requires-dist = [ { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.2,<2" }, { name = "llama-index-core", marker = "extra == 'llamaindex'", specifier = ">=0.14.23,<0.15" }, { name = "onnxruntime", marker = "python_full_version < '3.11' and extra == 'crewai'", specifier = ">=1.14,<1.24" }, + { name = "opentelemetry-sdk", marker = "extra == 'dev'", specifier = ">=1.39,<2" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.39,<2" }, { name = "pydantic-ai-slim", marker = "extra == 'pydantic-ai'", specifier = ">=2.0,<3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3,<2" }, { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2" }, ] -provides-extras = ["dev", "langchain", "langgraph", "crewai", "llamaindex", "pydantic-ai"] +provides-extras = ["dev", "langchain", "langgraph", "crewai", "llamaindex", "pydantic-ai", "otel"] [[package]] name = "filelock" diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 8c00d453c..26d8cf653 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -82,6 +82,49 @@ is unreachable. ### 2. Adapters +#### OpenTelemetry (opt in) + +If your app already emits OpenTelemetry spans, send them through FailproofAI Cloud +without replacing its framework adapters. Install OpenTelemetry separately: + +```bash +npm install @opentelemetry/api @opentelemetry/sdk-trace-base +``` + +```ts +import * as failproofai from "@failproofai/sdk"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import type { SpanProcessor } from "@opentelemetry/sdk-trace-base"; + +const spanProcessors: SpanProcessor[] = []; // include your existing processors +await failproofai.instrument("otel", { spanProcessors }); +const provider = new BasicTracerProvider({ spanProcessors }); +const tracer = provider.getTracer("my-agent"); +const span = tracer.startSpan("agent", { + attributes: { "gen_ai.operation.name": "invoke_agent" }, +}); +span.end(); +await provider.forceFlush(); +``` + +With no options, `instrument("otel")` creates a provider only if none is registered. +For an existing OpenTelemetry v2 provider, add the processor before constructing +the provider, as above. For providers exposing `addSpanProcessor`, pass `{ provider }`. +You can also supply `new failproofai.OtelSpanExporter()` to your own span processor. +`uninstrument("otel")` disables only FailproofAI's processor; other exporters keep working. + +The exporter writes OTLP/JSON export requests durably into the local relay spool +(`$FAILPROOFAI_HOME/state/spool-otlp`, otherwise `~/.failproofai/state/spool-otlp`). +It never sends HTTP or reads the Cloud key. Connect the daemon with `failproofai config` +and ask an admin to enable Settings → OpenTelemetry. No local TCP listener is +needed for SDK exports. `spoolDir` is an explicit override for tests or managed +deployments. `configure({ baseDir })` still applies only to the existing event adapters. + +OTEL is never auto-enabled by a bare `instrument()`. The SDK still declares zero +hard runtime dependencies. Your spans control content capture; do not export +sensitive attributes unless intended. Export related spans together for complete +agent traces; call `provider.forceFlush()` before a short-lived process exits. + ```ts await failproofai.instrument(); // whatever it can find await failproofai.instrument("langchain"); // exactly one diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index e4d40e44b..b17e1ca3e 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -13,6 +13,8 @@ }, "devDependencies": { "@eslint/js": "^9.38.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/sdk-trace-base": "^2.12.0", "@types/node": "^22.10.2", "eslint": "^9.38.0", "typescript": "^5.9.2", @@ -281,6 +283,56 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, + "engines": { "node": ">=8.0.0" } + }, + "node_modules/@opentelemetry/core": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", + "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", + "dev": true, + "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, + "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" }, + "engines": { "node": "^18.19.0 || >=20.6.0" } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", + "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", + "dev": true, + "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, + "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" }, + "engines": { "node": "^18.19.0 || >=20.6.0" } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", + "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", + "dev": true, + "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/resources": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, + "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" }, + "engines": { "node": "^18.19.0 || >=20.6.0" } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", + "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", + "dev": true, + "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/resources": "2.12.0", "@opentelemetry/sdk-trace": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, + "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" }, + "engines": { "node": "^18.19.0 || >=20.6.0" } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "engines": { "node": ">=14" } + }, "node_modules/@oxc-project/types": { "version": "0.150.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 002fb18c1..e03b3bd42 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -389,6 +389,8 @@ }, "devDependencies": { "@eslint/js": "^9.38.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/sdk-trace-base": "^2.12.0", "@types/node": "^22.10.2", "eslint": "^9.38.0", "typescript": "^5.9.2", diff --git a/sdk/typescript/src/edge/index.ts b/sdk/typescript/src/edge/index.ts index e9ff394cd..ec508f60b 100644 --- a/sdk/typescript/src/edge/index.ts +++ b/sdk/typescript/src/edge/index.ts @@ -42,6 +42,15 @@ import { notice } from "./notice.js"; export { VERSION as version }; export const DEFAULT_AGENT_ID = "main"; +/** File export is unavailable at the Edge, like the other telemetry surfaces. */ +export class OtelSpanExporter { + export(_spans: unknown[], callback: (result: { code: number }) => void): void { + notice(); + callback({ code: 0 }); + } + forceFlush(): Promise { return Promise.resolve(); } + shutdown(): Promise { return Promise.resolve(); } +} /** The same registered symbol as the Node build's, so comparisons still hold. */ export const AUTO: unique symbol = Symbol.for("failproofai.AUTO") as never; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index c32437283..db9ed6c86 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -88,6 +88,8 @@ export { uninstrument, } from "./integrations/index.js"; export type { FrameworkName, InstrumentOptions } from "./integrations/index.js"; +export { OtelSpanExporter } from "./integrations/otel.js"; +export type { OtelReadableSpan, OtelSpanExporterOptions } from "./integrations/otel.js"; export interface ConfigureOptions { /** diff --git a/sdk/typescript/src/integrations/index.ts b/sdk/typescript/src/integrations/index.ts index e2b9181b7..f0ab6543b 100644 --- a/sdk/typescript/src/integrations/index.ts +++ b/sdk/typescript/src/integrations/index.ts @@ -52,13 +52,14 @@ import * as compat from "./compat.js"; import type { Adapter } from "./core.js"; import * as core from "./core.js"; -export type FrameworkName = "langchain" | "ai" | "mastra" | "llamaindex"; +export type FrameworkName = "langchain" | "ai" | "mastra" | "llamaindex" | "otel"; const REGISTRY: Record Promise<{ adapter: Adapter }>> = { langchain: () => import("./langchain.js"), ai: () => import("./ai.js"), mastra: () => import("./mastra.js"), llamaindex: () => import("./llamaindex.js"), + otel: () => import("./otel.js"), }; /** @@ -90,6 +91,8 @@ const DETECT: Record = { ai: ["ai"], mastra: ["@mastra/core"], llamaindex: ["llamaindex", "@llamaindex/core"], + // Never auto-enable a second reporting path on an existing application. + otel: [], }; const active = new Map(); diff --git a/sdk/typescript/src/integrations/otel.ts b/sdk/typescript/src/integrations/otel.ts new file mode 100644 index 000000000..c52c9444d --- /dev/null +++ b/sdk/typescript/src/integrations/otel.ts @@ -0,0 +1,204 @@ +/** + * Opt-in OTLP/JSON SpanExporter. Only disk I/O: the daemon authenticates and + * delivers opaque batches. No OpenTelemetry import until instrument("otel"). + */ +import { randomUUID } from "node:crypto"; +import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { importModule, resolveFrom, tryRequire } from "../node-require.js"; +import type { Adapter } from "./core.js"; + +type ObjectValue = Record; +export interface OtelReadableSpan { + name: string; + kind: number; + spanContext(): { traceId: string; spanId: string; traceFlags?: number; traceState?: { serialize(): string } }; + parentSpanId?: string; + parentSpanContext?: { spanId: string }; + startTime: readonly [number, number]; + endTime: readonly [number, number]; + attributes: ObjectValue; + status: { code: number; message?: string }; + resource?: { attributes: ObjectValue; schemaUrl?: string }; + instrumentationScope?: { name: string; version?: string; schemaUrl?: string }; + instrumentationLibrary?: { name: string; version?: string; schemaUrl?: string }; + events?: readonly { name: string; time: readonly [number, number]; attributes?: ObjectValue; droppedAttributesCount?: number }[]; + links?: readonly { context: { traceId: string; spanId: string; traceFlags?: number; traceState?: { serialize(): string } }; attributes?: ObjectValue; droppedAttributesCount?: number }[]; + droppedAttributesCount?: number; + droppedEventsCount?: number; + droppedLinksCount?: number; +} +export interface OtelExportResult { code: number; error?: Error } +export interface OtelSpanExporterOptions { spoolDir?: string } +const MAX_BYTES = 8 * 1024 * 1024; +const metadata = '{"path":"/v1/traces","content_type":"application/json","encoding":null}\n'; + +function anyValue(value: unknown): ObjectValue { + if (typeof value === "string") return { stringValue: value }; + if (typeof value === "boolean") return { boolValue: value }; + if (typeof value === "bigint") return { intValue: value.toString() }; + if (typeof value === "number") { + if (!Number.isFinite(value)) return { doubleValue: String(value) }; + return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value }; + } + if (value instanceof Uint8Array) return { bytesValue: Buffer.from(value).toString("base64") }; + if (Array.isArray(value)) return { arrayValue: { values: value.map(anyValue) } }; + if (value && typeof value === "object") return { kvlistValue: { values: attributes(value as ObjectValue) } }; + return {}; +} +function attributes(value: ObjectValue = {}): ObjectValue[] { + return Object.entries(value).filter(([, v]) => v !== undefined && v !== null) + .map(([key, v]) => ({ key, value: anyValue(v) })); +} +function nanos(time: readonly [number, number]): string { + return (BigInt(time[0]) * 1_000_000_000n + BigInt(time[1])).toString(); +} +function resourceSpan(span: OtelReadableSpan): ObjectValue { + const context = span.spanContext(); + const scope = span.instrumentationScope ?? span.instrumentationLibrary; + return { + resource: { attributes: attributes(span.resource?.attributes) }, + ...(span.resource?.schemaUrl ? { schemaUrl: span.resource.schemaUrl } : {}), + scopeSpans: [{ + scope: { name: scope?.name ?? "", ...(scope?.version ? { version: scope.version } : {}) }, + ...(scope?.schemaUrl ? { schemaUrl: scope.schemaUrl } : {}), + spans: [{ + traceId: context.traceId, spanId: context.spanId, + ...(span.parentSpanContext?.spanId || span.parentSpanId ? { parentSpanId: span.parentSpanContext?.spanId ?? span.parentSpanId } : {}), + ...(context.traceState ? { traceState: context.traceState.serialize() } : {}), + flags: context.traceFlags ?? 0, name: span.name, kind: span.kind + 1, + startTimeUnixNano: nanos(span.startTime), endTimeUnixNano: nanos(span.endTime), + attributes: attributes(span.attributes), status: span.status, + droppedAttributesCount: span.droppedAttributesCount ?? 0, + droppedEventsCount: span.droppedEventsCount ?? 0, droppedLinksCount: span.droppedLinksCount ?? 0, + events: (span.events ?? []).map(event => ({ + name: event.name, timeUnixNano: nanos(event.time), attributes: attributes(event.attributes), + droppedAttributesCount: event.droppedAttributesCount ?? 0, + })), + links: (span.links ?? []).map(link => ({ + traceId: link.context.traceId, spanId: link.context.spanId, flags: link.context.traceFlags ?? 0, + ...(link.context.traceState ? { traceState: link.context.traceState.serialize() } : {}), + attributes: attributes(link.attributes), droppedAttributesCount: link.droppedAttributesCount ?? 0, + })), + }], + }], + }; +} + +/** Structural implementation of OpenTelemetry's SpanExporter; zero hard deps. */ +export class OtelSpanExporter { + private stopped = false; + private readonly directory?: string; + constructor(options: OtelSpanExporterOptions = {}) { this.directory = options.spoolDir; } + + export(spans: readonly OtelReadableSpan[], callback: (result: OtelExportResult) => void): void { + let result: OtelExportResult = { code: 0 }; + try { + if (this.stopped) throw new Error("OTEL exporter has shut down."); + const dir = this.directory ?? join(process.env.FAILPROOFAI_HOME || join(homedir(), ".failproofai"), "state", "spool-otlp"); + // Encode each span once and split batches at the relay's request limit. + const chunks: string[] = []; + let items: string[] = []; + let size = Buffer.byteLength('{"resourceSpans":[]}'); + for (const span of spans) { + const encoded = JSON.stringify(resourceSpan(span)); + const bytes = Buffer.byteLength(encoded); + if (bytes + 20 > MAX_BYTES) throw new Error("OTEL span exceeds the 8 MiB relay limit."); + if (size + bytes + 1 > MAX_BYTES) { + chunks.push(`{"resourceSpans":[${items.join(",")}]}`); items = []; size = 20; + } + items.push(encoded); size += bytes + 1; + } + if (items.length) chunks.push(`{"resourceSpans":[${items.join(",")}]}`); + for (const body of chunks) this.publish(dir, body); + } catch (error) { + result = { code: 1, error: error instanceof Error ? error : new Error(String(error)) }; + } + callback(result); + } + private publish(dir: string, body: string): void { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const name = `otlp-sdk-${randomUUID()}`; + const temp = join(dir, `.${name}.tmp`); + let fd: number | undefined; + try { + fd = openSync(temp, "wx", 0o600); + const bytes = Buffer.from(metadata + body); + let offset = 0; + while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset); + fsyncSync(fd); closeSync(fd); fd = undefined; + renameSync(temp, join(dir, `${name}.jsonl`)); + if (process.platform !== "win32") { + const directory = openSync(dir, "r"); + try { fsyncSync(directory); } finally { closeSync(directory); } + } + } finally { + if (fd !== undefined) closeSync(fd); + rmSync(temp, { force: true }); + } + } + forceFlush(): Promise { return Promise.resolve(); } + shutdown(): Promise { this.stopped = true; return Promise.resolve(); } +} + +interface Processor { + onStart(...args: unknown[]): void; + onEnd(...args: unknown[]): void; + shutdown(): Promise; + forceFlush(): Promise; +} +interface Provider { addSpanProcessor(processor: Processor): void } +interface TraceSdk { + BatchSpanProcessor: new (exporter: OtelSpanExporter) => Processor; + BasicTracerProvider: new (options: { spanProcessors: Processor[] }) => unknown; +} +interface TraceApi { trace: { setGlobalTracerProvider(provider: unknown): boolean } } +let installed: { enabled: boolean; processor: Processor; list?: Processor[] } | null = null; +export const adapter: Adapter = { + name: "otel", + async install(options = {}) { + const provider = options.provider as Provider | undefined; + const list = options.spanProcessors as Processor[] | undefined; + if (provider && typeof provider.addSpanProcessor !== "function") { + throw new Error('instrument("otel") needs { spanProcessors } before constructing an OTEL v2 provider, or a provider with addSpanProcessor().'); + } + const path = resolveFrom("@opentelemetry/sdk-trace-base"); + if (!path) throw new Error('Install @opentelemetry/sdk-trace-base to use instrument("otel").'); + const module = tryRequire("@opentelemetry/sdk-trace-base") ?? + await importModule(path) as TraceSdk; + const exporter = new OtelSpanExporter({ spoolDir: options.spoolDir as string | undefined }); + const batch = new module.BatchSpanProcessor(exporter); + const state = { enabled: true, processor: batch, list }; + // Providers have no portable remove API. A gate makes uninstall effective + // without shutting down anybody else's processors or provider. + const gate: Processor = { + onStart: (...args) => { if (state.enabled) batch.onStart(...args); }, + onEnd: (...args) => { if (state.enabled) batch.onEnd(...args); }, + forceFlush: () => state.enabled ? batch.forceFlush() : Promise.resolve(), + shutdown: () => batch.shutdown(), + }; + installed = state; + if (Array.isArray(list)) { list.push(gate); state.processor = gate; } + else if (provider) { provider.addSpanProcessor(gate); state.processor = gate; } + else { + state.processor = gate; + const apiPath = resolveFrom("@opentelemetry/api"); + if (!apiPath) throw new Error("Install @opentelemetry/api to register the OTEL provider."); + const api = tryRequire("@opentelemetry/api") ?? await importModule(apiPath) as TraceApi; + if (!api.trace.setGlobalTracerProvider(new module.BasicTracerProvider({ spanProcessors: [gate] }))) { + throw new Error('An OTEL provider is already registered; use instrument("otel", { spanProcessors }) before constructing it.'); + } + } + }, + uninstall() { + if (!installed) return; + installed.enabled = false; + if (installed.list) { + const index = installed.list.indexOf(installed.processor); + if (index >= 0) installed.list.splice(index, 1); + } + void installed.processor.shutdown().catch(() => { /* SDK teardown never throws into the app. */ }); + installed = null; + }, +}; diff --git a/sdk/typescript/test/integrations.test.ts b/sdk/typescript/test/integrations.test.ts index 91c502932..a63a187ba 100644 --- a/sdk/typescript/test/integrations.test.ts +++ b/sdk/typescript/test/integrations.test.ts @@ -324,7 +324,7 @@ describe("RunTracker", () => { describe("the registry", () => { it("lists the frameworks it can instrument", () => { - expect(available()).toEqual(["ai", "langchain", "llamaindex", "mastra"]); + expect(available()).toEqual(["ai", "langchain", "llamaindex", "mastra", "otel"]); }); it("throws on an unknown name, listing the valid ones", async () => { diff --git a/sdk/typescript/test/otel.test.ts b/sdk/typescript/test/otel.test.ts new file mode 100644 index 000000000..fbccd6864 --- /dev/null +++ b/sdk/typescript/test/otel.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BasicTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import type { SpanProcessor, ReadableSpan } from "@opentelemetry/sdk-trace-base"; +import { ROOT_CONTEXT, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; +import { OtelSpanExporter } from "../src/index.js"; +import { instrument, uninstrument, activeFrameworks } from "../src/integrations/index.js"; +import { setStrict } from "../src/integrations/core.js"; + +let home: string; +let previousHome: string | undefined; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-otel-sdk-")); + previousHome = process.env.FAILPROOFAI_HOME; + process.env.FAILPROOFAI_HOME = home; +}); +afterEach(() => { + uninstrument("otel"); + setStrict(null); + if (previousHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); +}); +function files() { return readdirSync(join(home, "state/spool-otlp")); } +function body(name: string) { + const text = readFileSync(join(home, "state/spool-otlp", name), "utf8"); + const split = text.indexOf("\n"); + expect(JSON.parse(text.slice(0, split))).toEqual({ + path: "/v1/traces", content_type: "application/json", encoding: null, + }); + return JSON.parse(text.slice(split + 1)) as { resourceSpans: Array<{ + resource: { attributes: unknown[] }; + scopeSpans: Array<{ scope: { name: string }; spans: Array> }>; + }> }; +} + +describe("OTLP/JSON SpanExporter", () => { + it("exports real ended spans, links, events, precise nanos, status and typed attributes durably without network", async () => { + const exporter = new OtelSpanExporter(); + const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); + const tracer = provider.getTracer("test-scope", "1.0"); + const parent = tracer.startSpan("agent", { startTime: [1780000000, 123], attributes: { "gen_ai.operation.name": "invoke_agent" } }); + const child = tracer.startSpan("chat", { + startTime: [1780000000, 456], kind: SpanKind.CLIENT, + attributes: { "gen_ai.operation.name": "chat", count: 42, enabled: true, decimal: 1.5, values: ["a", "b"] }, + links: [{ context: parent.spanContext(), attributes: { linked: true } }], + }, trace.setSpanContext(ROOT_CONTEXT, parent.spanContext())); + child.addEvent("prompt", { text: "hello" }, [1780000000, 789]); + child.setStatus({ code: SpanStatusCode.ERROR, message: "failed" }); + child.end([1780000001, 12]); parent.end([1780000001, 13]); + await provider.forceFlush(); + expect(files()).toHaveLength(2); + const spans = files().flatMap(name => body(name).resourceSpans.flatMap(r => r.scopeSpans.flatMap(s => s.spans))); + const output = spans.find(span => span.name === "chat")!; + expect(output).toMatchObject({ + traceId: child.spanContext().traceId, spanId: child.spanContext().spanId, + parentSpanId: parent.spanContext().spanId, kind: 3, + startTimeUnixNano: "1780000000000000456", endTimeUnixNano: "1780000001000000012", + status: { code: 2, message: "failed" }, + events: [{ name: "prompt", timeUnixNano: "1780000000000000789" }], + links: [{ traceId: parent.spanContext().traceId, spanId: parent.spanContext().spanId }], + }); + expect(output.attributes).toContainEqual({ key: "count", value: { intValue: "42" } }); + expect(output.attributes).toContainEqual({ key: "values", value: { arrayValue: { values: [{ stringValue: "a" }, { stringValue: "b" }] } } }); + for (const name of files()) { + expect(name).toMatch(/^otlp-sdk-.*\.jsonl$/); + expect(statSync(join(home, "state/spool-otlp", name)).mode & 0o777).toBe(0o600); + } + await provider.shutdown(); + }); + + it("reports disk and post-shutdown failures through the callback, once", async () => { + const file = join(home, "not-a-directory"); + writeFileSync(file, "x"); + const exporter = new OtelSpanExporter({ spoolDir: file }); + const provider = new BasicTracerProvider(); + const span = provider.getTracer("test").startSpan("test"); + span.end(); + const results: number[] = []; + exporter.export([span as unknown as ReadableSpan], result => results.push(result.code)); + await exporter.shutdown(); + exporter.export([], result => results.push(result.code)); + expect(results).toEqual([1, 1]); + await provider.shutdown(); + }); + + it("splits an export above 8 MiB into complete bounded requests", async () => { + const provider = new BasicTracerProvider({ spanLimits: { attributeValueLengthLimit: 10_000_000 } }); + const span = provider.getTracer("test").startSpan("big", { attributes: { content: "x".repeat(4 * 1024 * 1024) } }); + span.end(); + const results: number[] = []; + new OtelSpanExporter().export([span as unknown as ReadableSpan, span as unknown as ReadableSpan], result => results.push(result.code)); + expect(results).toEqual([0]); + expect(files()).toHaveLength(2); + for (const name of files()) expect(body(name).resourceSpans).toHaveLength(1); + await provider.shutdown(); + }); + + it("instrument('otel') adds one gated processor and leaves other exporters unchanged", async () => { + setStrict(true); + const processors: SpanProcessor[] = []; + expect(await instrument("otel", { spanProcessors: processors })).toEqual(["otel"]); + expect(await instrument("otel", { spanProcessors: processors })).toEqual([]); + const observed: string[] = []; + const existing: SpanProcessor = { + onStart() {}, onEnd(span) { observed.push(span.name); }, + forceFlush: () => Promise.resolve(), shutdown: () => Promise.resolve(), + }; + const provider = new BasicTracerProvider({ spanProcessors: [existing, ...processors] }); + const tracer = provider.getTracer("app"); + tracer.startSpan("before").end(); + await provider.forceFlush(); + expect(files()).toHaveLength(1); + expect(activeFrameworks()).toContain("otel"); + expect(uninstrument("otel")).toEqual(["otel"]); + tracer.startSpan("after").end(); + await provider.forceFlush(); + expect(files()).toHaveLength(1); + expect(observed).toEqual(["before", "after"]); + expect(processors).toHaveLength(0); + await provider.shutdown(); + }); +}); From b6b2aa6de55710cdf9c44ef0a3025447f0fca529 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:55:36 +0000 Subject: [PATCH 08/14] fix(otel): initialize delivery before collector startup milestone --- crates/failproofaid/src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index c2fb565e3..38ceb4c5b 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -801,6 +801,11 @@ fn collector_tasks() -> Vec { } }; + // Initialize the additive lane before announcing this deployment. TLS + // client construction can be slow; doing it after the startup milestone + // widens the config-snapshot race for a credential rotated at startup. + let otlp_tasks = fpai_collect::otlp::tasks(home.clone(), ingest.clone(), &cfg.settings.otlp); + eprintln!( "[failproofaid] collector enabled: sessions={} hooks={} ({:?}) -> {}", cfg.settings.sessions, cfg.settings.hooks, cfg.settings.hooks_verbosity, ingest.url, @@ -1264,7 +1269,7 @@ fn collector_tasks() -> Vec { !HARNESS_KEYS.contains(&source) && source != "claude-subagent" || cfg.settings.sessions_for(source) }); - tasks.extend(fpai_collect::otlp::tasks(home, ingest, &cfg.settings.otlp)); + tasks.extend(otlp_tasks); tasks.extend([ // Latency: delivers a batch within milliseconds of it being published. From 30fb5ffc7172171d58cfbc920dfab0e2ff7f5687 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:59:18 +0000 Subject: [PATCH 09/14] test(otel): isolate optional-library and initial-render assumptions --- __tests__/dashboard/jev-activity-row.test.tsx | 7 +++++++ sdk/typescript/integration/ai.test.ts | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/__tests__/dashboard/jev-activity-row.test.tsx b/__tests__/dashboard/jev-activity-row.test.tsx index 32c8f1892..dad9032f3 100644 --- a/__tests__/dashboard/jev-activity-row.test.tsx +++ b/__tests__/dashboard/jev-activity-row.test.tsx @@ -137,6 +137,7 @@ vi.mock("@/app/actions/update-policy-params", () => ({ })); import HooksClient from "@/app/policies/hooks-client"; +import { getHookActivityAction } from "@/app/actions/get-hook-activity"; /** The table row whose Tool cell reads `tool`. */ async function rowFor(tool: string): Promise { @@ -162,6 +163,11 @@ async function rowFor(tool: string): Promise { * it, so a blind retry would close the panel it had just opened. */ async function openRow(user: ReturnType, tool: string, marker: string) { + // The initial filter effect refetches and clears expandedRow after 300ms. + // Waiting only for a visible row can click BEFORE that effect, so a panel + // successfully opened by this helper closes just after the assertion. Wait + // for the initial refetch before interacting; keep all UI assertions intact. + await waitFor(() => expect(vi.mocked(getHookActivityAction).mock.calls.length).toBeGreaterThanOrEqual(2)); await waitFor(async () => { if (screen.queryByText(marker, { exact: false })) return; await user.click(await rowFor(tool)); @@ -172,6 +178,7 @@ async function openRow(user: ReturnType, tool: string, m describe("the activity tab with Jev rows", () => { beforeEach(() => { entries = []; + vi.mocked(getHookActivityAction).mockClear(); }); it("marks a Jev clear in the Decision cell and explains it in the detail panel", async () => { diff --git a/sdk/typescript/integration/ai.test.ts b/sdk/typescript/integration/ai.test.ts index 3ed9106a7..dd5921685 100644 --- a/sdk/typescript/integration/ai.test.ts +++ b/sdk/typescript/integration/ai.test.ts @@ -258,8 +258,15 @@ describe.each(FIXTURES)("%s", (fixture) => { }; expect(report.text).toBe("It is 20C in Paris."); if (major >= 7) { - // v7 has no OpenTelemetry dependency; the integration records the call. - expect(report.customer).toBe("absent"); + // v7 has no OpenTelemetry dependency of its own. The optional OTEL + // exporter's dev tests can make it resolvable from the parent SDK + // project; when present, their provider must still own the global slot + // and receive only their service span (ai 7 emits no OTEL spans). + if (report.customer !== "absent") { + expect(report.customer, describeTrace(result)).toEqual({ + registered: true, ended: ["http.request"], + }); + } expect(shape(result.events), describeTrace(result)).toEqual(GEN); return; } From e1685785a37fd4289ebbfd86ab3c5593464d40b2 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:15:02 +0000 Subject: [PATCH 10/14] fix(otel): lazy imports, isolated setup and precise local configuration --- CHANGELOG.md | 1 + __tests__/hooks/configure-wizard.test.ts | 23 ++++++++++ __tests__/hooks/otel.test.ts | 45 +++++++++++++++++++ bin/failproofai.mjs | 24 +++++++--- .../failproofai_sdk/integrations/otel.py | 6 +-- sdk/python/tests/test_otel.py | 29 ++++++++++++ src/hooks/configure-wizard.ts | 2 + src/hooks/otel-cli.ts | 2 +- src/hooks/otel-writers.ts | 5 ++- 9 files changed, 125 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b887273d1..9abfe46ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes - Preserve Codex OTEL header comments, omit local Claude auth-header settings, and require an executable or Copilot extension before configuring VS Code (#880). +- Keep Python OTEL imports lazy, honor wizard home isolation, label direct loopback destinations correctly, omit local Codex auth headers, and scope help options to each verb (#880). ### Dependencies diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 6af2e1b9e..d7aea4f8a 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -121,6 +121,7 @@ vi.mock("../../src/hooks/integrations", async (importOriginal) => { import { selectOne, multiSelect, promptText, outro, type TTYIn, type TTYOut } from "../../src/hooks/tui"; import { connectToCloud } from "../../src/hooks/cloud-connection"; import { validateIngestKey } from "../../src/hooks/collector-config"; +import * as otelCli from "../../src/hooks/otel-cli"; import { installHooks } from "../../src/hooks/manager"; import { isDaemonSupportedPlatform, @@ -1391,6 +1392,28 @@ describe("connect step", () => { }); }); + it("passes the resolved HOME into OTEL enable after the optional wizard step", async () => { + const enable = vi.spyOn(otelCli, "runOtelCommand").mockResolvedValue({ + lines: ["Gemini CLI OTEL enabled"], exitCode: 0, + }); + vi.mocked(selectOne) + .mockResolvedValueOnce("key") + .mockResolvedValueOnce(true) + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["gemini"]); + try { + const result = await runConfigureWizard(ttyIO()); + expect(result.applied).toBe(true); + expect(result.connected).toBe(true); + expect(enable).toHaveBeenCalledExactlyOnceWith( + ["enable", "gemini"], + expect.objectContaining({ home: fileHome }), + ); + } finally { + enable.mockRestore(); + } + }); + it("never asks for the endpoint — only the key", async () => { // The regression this guards: re-introducing the URL prompt silently makes // the key the SECOND answer again, so every scripted or muscle-memory diff --git a/__tests__/hooks/otel.test.ts b/__tests__/hooks/otel.test.ts index 32593dbb8..88d9f8494 100644 --- a/__tests__/hooks/otel.test.ts +++ b/__tests__/hooks/otel.test.ts @@ -156,6 +156,23 @@ describe("managed OTEL settings", () => { expect(() => editAgentConfig("[broken", "codex", writes)).toThrow(); }); + it("local Codex omits headers from all exporters and restores original config bytes", async () => { + const path = agentConfigPath("codex", home); + const before = '# kept\n[otel] # old config\nexporter = { otlp-http = { endpoint="https://old/v1/logs", protocol="json", headers={Authorization="Bearer old"} } }\n[other]\nkey="kept"\n'; + seed(path, before); + const enabled = await runOtelCommand(["enable", "codex", "--local"], options()); + expect(enabled.exitCode, enabled.lines.join("\n")).toBe(0); + const text = readFileSync(path, "utf8"); + const parsed = parseAgentConfig(text, "codex").otel as Record }>; + for (const name of ["exporter", "trace_exporter", "metrics_exporter"]) { + expect(parsed[name]["otlp-http"]).not.toHaveProperty("headers"); + expect(parsed[name]["otlp-http"].endpoint).toContain("http://127.0.0.1:4318/v1/"); + } + expect(text).not.toContain("headers"); + expect((await runOtelCommand(["disable", "codex"], options())).exitCode).toBe(0); + expect(readFileSync(path, "utf8")).toBe(before); + }); + it.each(["gemini", "copilot"] as const)("writes %s with automatic auth-free relay and restores JSONC byte-equal", async agent => { const path = agentConfigPath(agent, home); const before = '{\n// user comment\n"editor.fontSize": 13,\n"telemetry": {"unknown": 42},\n}\n'; @@ -278,6 +295,17 @@ describe("opt-in, one path and status", () => { expect(identity.kind === "ok" && identity.identity.otelIngest).toBeUndefined(); }); + it("status uses recorded relay mode, not a loopback Cloud hostname", async () => { + process.env.FAILPROOFAI_INGEST_URL = "http://127.0.0.1:18765/v1/events"; + await runOtelCommand(["enable", "codex"], options()); + let line = (await runOtelCommand(["status"], options())).lines.find(line => line.startsWith("Codex:")); + expect(line).toContain("destination: Cloud (http://127.0.0.1:18765)"); + expect(line).not.toContain("local relay"); + await runOtelCommand(["enable", "codex", "--local"], options()); + line = (await runOtelCommand(["status"], options())).lines.find(line => line.startsWith("Codex:")); + expect(line).toContain("destination: local relay (http://127.0.0.1:4318)"); + }); + it("reports unmanaged OTEL and warns but writes when org ingest is off", async () => { seed(agentConfigPath("codex", home), '[otel]\nexporter = { "otlp-http" = { endpoint="http://external/v1/logs", protocol="json" } }\n'); expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Codex: on, not managed"); @@ -331,4 +359,21 @@ describe("opt-in, one path and status", () => { expect(result.stdout.toLowerCase()).toContain("usage"); expect(result.stdout.toLowerCase()).toContain("notes"); }); + + it.each([ + { verb: "status", flags: [] }, + { verb: "enable", flags: ["--local", "--no-content", "--yes"] }, + { verb: "disable", flags: [] }, + { verb: "env", flags: ["--service", "--local"] }, + ])("$verb help mentions only flags that verb accepts", ({ verb, flags }) => { + const result = spawnSync("bun", ["bin/failproofai.mjs", "otel", verb, "--help"], { + cwd: join(__dirname, "../.."), env: { ...process.env, HOME: home, FAILPROOFAI_TELEMETRY_DISABLED: "1" }, + encoding: "utf8", timeout: 15000, + }); + expect(result.status, result.stderr).toBe(0); + for (const flag of ["--local", "--no-content", "--yes", "--service"]) { + if (flags.includes(flag)) expect(result.stdout).toContain(flag); + else expect(result.stdout).not.toContain(flag); + } + }); }); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 751fefd99..2e101883b 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -818,21 +818,31 @@ async function runCli() { disable: "failproofai otel disable ", env: "failproofai otel env [--service ] [--local]", }; + const options = { + enable: [ + ["--local", "Use the loopback OTLP relay. Automatic for Gemini CLI and Copilot."], + ["--no-content", "Do not capture prompt text or tool details."], + ["--yes", "Accept switching this agent from transcript upload to OTEL."], + ], + env: [ + ["--service ", "Service name for env (default: my-agent)."], + ["--local", "Use the loopback OTLP relay."], + ], + status: [], + disable: [], + }; + const acceptedOptions = verb ? options[verb] : [...options.enable, options.env[0]]; await printHelp({ command: verb ? `otel ${verb}` : "otel", tagline: "opt-in OpenTelemetry from agents and apps to FailproofAI Cloud", sections: [ { label: "usage", entries: (verb ? [usages[verb]] : Object.values(usages)).map(usage => [usage]) }, { label: "agents", lines: ["claude, codex, gemini, copilot; all configures every available agent."] }, - { label: "options", entries: [ - ["--local", "Use the loopback OTLP relay. Automatic for Gemini CLI and Copilot."], - ["--no-content", "Do not capture prompt text or tool details."], - ["--yes", "Accept switching this agent from transcript upload to OTEL."], - ["--service ", "Service name for env (default: my-agent)."], - ] }, + ...(acceptedOptions.length ? [{ label: "options", entries: acceptedOptions }] : []), { label: "notes", lines: [ "Off by default. Run `failproofai config` first to configure your Cloud key.", - "Text capture is on unless --no-content. Disable restores only managed settings.", + ...(verb === "enable" || !verb ? ["Text capture is on unless --no-content."] : []), + "Disable restores only managed settings.", "An org admin must enable Settings → OpenTelemetry in Cloud.", ] }, ], diff --git a/sdk/python/failproofai_sdk/integrations/otel.py b/sdk/python/failproofai_sdk/integrations/otel.py index bce31b141..1bc66f511 100644 --- a/sdk/python/failproofai_sdk/integrations/otel.py +++ b/sdk/python/failproofai_sdk/integrations/otel.py @@ -8,8 +8,6 @@ import uuid from pathlib import Path -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult, BatchSpanProcessor - _MAX_BYTES = 8 * 1024 * 1024 _METADATA = b'{"path":"/v1/traces","content_type":"application/json","encoding":null}\n' @@ -73,7 +71,7 @@ def _resource_span(span): } -class OtelSpanExporter(SpanExporter): +class OtelSpanExporter: """Exports standard spans durably to the daemon's raw OTLP spool, never HTTP.""" def __init__(self, *, spool_dir=None): @@ -82,6 +80,7 @@ def __init__(self, *, spool_dir=None): self._lock = threading.Lock() def export(self, spans): + from opentelemetry.sdk.trace.export import SpanExportResult with self._lock: if self._stopped: return SpanExportResult.FAILURE @@ -170,6 +169,7 @@ def __init__(self): def install(self, **options): from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor provider = options.get("provider") or trace.get_tracer_provider() if not hasattr(provider, "add_span_processor"): if options.get("provider") is not None: diff --git a/sdk/python/tests/test_otel.py b/sdk/python/tests/test_otel.py index ac6f38156..88164e454 100644 --- a/sdk/python/tests/test_otel.py +++ b/sdk/python/tests/test_otel.py @@ -1,4 +1,6 @@ import json +import subprocess +import sys from pathlib import Path import pytest @@ -11,6 +13,33 @@ from failproofai_sdk.integrations import _core +def test_module_and_exporter_import_without_opentelemetry(): + package_root = Path(failproofai_sdk.__file__).resolve().parent.parent + result = subprocess.run( + [sys.executable, "-I", "-S", "-c", f""" +import importlib.abc +import sys +sys.path.insert(0, {str(package_root)!r}) +class AbsentOpenTelemetry(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "opentelemetry" or fullname.startswith("opentelemetry."): + raise ModuleNotFoundError("OpenTelemetry is not installed") +sys.meta_path.insert(0, AbsentOpenTelemetry()) +from failproofai_sdk.integrations.otel import OtelSpanExporter +import failproofai_sdk +assert failproofai_sdk.OtelSpanExporter is OtelSpanExporter +exporter = OtelSpanExporter() +assert exporter.force_flush() +exporter.shutdown() +assert not any(name.startswith("opentelemetry") for name in sys.modules) +print("Imported OTEL module and exporter without OpenTelemetry") +"""], + capture_output=True, text=True, timeout=15, + ) + assert result.returncode == 0, result.stderr + assert "without OpenTelemetry" in result.stdout + + @pytest.fixture(autouse=True) def enabled_otel(monkeypatch): # The CrewAI test module disables its own vendor telemetry at collection. diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index 9269a1708..ec87b749a 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -731,6 +731,7 @@ export async function runConfigureWizard( const stdin: TTYIn = io.stdin ?? process.stdin; const stdout: TTYOut = io.stdout ?? process.stdout; const cwd = process.cwd(); + const home = process.env.HOME || homedir(); // No terminal means no questions — not a refusal. // // `failproofai config` IS the authorisation: somebody typed the command whose @@ -1583,6 +1584,7 @@ export async function runConfigureWizard( }); if (Array.isArray(selected)) for (const agent of selected) { const result = await runOtelCommand(["enable", agent], { + home, onEvent: emit, confirm: async message => await selectOne({ message, diff --git a/src/hooks/otel-cli.ts b/src/hooks/otel-cli.ts index 53f1903dc..f4255c090 100644 --- a/src/hooks/otel-cli.ts +++ b/src/hooks/otel-cli.ts @@ -281,7 +281,7 @@ async function status(options: OtelOptions): Promise { const dest = record?.destination ?? getSetting(parsed, agent === "claude" ? ["env", "OTEL_EXPORTER_OTLP_ENDPOINT"] : agent === "gemini" ? ["telemetry", "otlpEndpoint"] : agent === "copilot" ? ["github.copilot.chat.otel.otlpEndpoint"] : ["otel", "exporter", "otlp-http", "endpoint"]); - const destination = dest ? `${record?.local || String(dest).startsWith("http://127.0.0.1:") ? "local relay" : "Cloud"} (${dest})` : "none"; + const destination = dest ? `${record?.local ? "local relay" : "Cloud"} (${dest})` : "none"; lines.push(`${AGENT_LABELS[agent]}: ${unreadable ? "unreadable settings" : on ? managed ? "on by failproofai" : "on, not managed" : "off"} · destination: ${destination} · org: ${org}`); } let health: JsonObject = {}; diff --git a/src/hooks/otel-writers.ts b/src/hooks/otel-writers.ts index 6f2962335..5efdd6363 100644 --- a/src/hooks/otel-writers.ts +++ b/src/hooks/otel-writers.ts @@ -185,6 +185,7 @@ export function agentOtelWrites(agent: OtelAgent, endpoint: string, key: string, ...(!local ? { OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer%20${encodeURIComponent(key)}` } : {}), OTEL_LOG_USER_PROMPTS: content ? "1" : "0", OTEL_LOG_TOOL_DETAILS: content ? "1" : "0", + // Tool output needs OTEL_LOG_TOOL_CONTENT; assistant responses follow OTEL_LOG_USER_PROMPTS, so omit OTEL_LOG_ASSISTANT_RESPONSES. OTEL_LOG_TOOL_CONTENT: content ? "1" : "0", }; if (agent === "claude") { @@ -196,7 +197,9 @@ export function agentOtelWrites(agent: OtelAgent, endpoint: string, key: string, return Object.entries(env).map(([k, value]) => ({ path: ["env", k], value })); } if (agent === "codex") { - const exporter = (signal: string) => ({ "otlp-http": { endpoint: `${endpoint}/v1/${signal}`, protocol: "json", headers: auth } }); + const exporter = (signal: string) => ({ "otlp-http": { + endpoint: `${endpoint}/v1/${signal}`, protocol: "json", ...(!local ? { headers: auth } : {}), + } }); return Object.entries({ exporter: exporter("logs"), trace_exporter: exporter("traces"), metrics_exporter: exporter("metrics"), log_user_prompt: content, From e5870a74a2f753870c71bacf3ea1f774d1260188 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:31:20 +0000 Subject: [PATCH 11/14] test(otel): account for additive delivery in config v2 collector checks --- crates/failproofaid/src/main.rs | 20 +++++++++++++++++-- .../failproofaid/tests/agent_selection_e2e.rs | 17 ++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index df2d5baa0..c59aff6cd 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -2282,6 +2282,8 @@ mod tests { "hermes:team", "spool-watcher", "spool-sweeper", + // SDK OTEL batches drain even when the TCP relay is off. + "otlp-delivery", ] .iter() .map(|s| s.to_string()) @@ -2314,6 +2316,7 @@ mod tests { "codex:box", "health", "hook-activity", + "otlp-delivery", "spool-sweeper", "spool-watcher", ], @@ -2367,7 +2370,13 @@ mod tests { names.sort(); assert_eq!( names, - ["health", "hook-activity", "spool-sweeper", "spool-watcher"] + [ + "health", + "hook-activity", + "otlp-delivery", + "spool-sweeper", + "spool-watcher" + ] ); } @@ -2387,7 +2396,14 @@ mod tests { fn every_task_the_collector_builds_has_a_row_in_the_agent_table() { // A source missing from the table would be started on every machine // with no selection and on none with one — silently either way. - let infrastructure = ["health", "hook-activity", "spool-watcher", "spool-sweeper"]; + let infrastructure = [ + "health", + "hook-activity", + "spool-watcher", + "spool-sweeper", + "otlp-delivery", + "otlp-relay", + ]; for name in task_names(&config_with_agents(None), &["prod"]) { let source = name.split(':').next().unwrap(); assert!( diff --git a/crates/failproofaid/tests/agent_selection_e2e.rs b/crates/failproofaid/tests/agent_selection_e2e.rs index 327f89bde..2635fd743 100644 --- a/crates/failproofaid/tests/agent_selection_e2e.rs +++ b/crates/failproofaid/tests/agent_selection_e2e.rs @@ -300,7 +300,7 @@ fn with_no_agents_key_every_source_starts() { let s = Scratch::new("all"); s.config(&config(None, "")); let daemon = spawn_daemon(&s); - daemon.wait_for("collector started tasks=18", 1); + daemon.wait_for("collector started tasks=19", 1); for source in [ "claude", "claude-subagent", @@ -337,9 +337,10 @@ fn an_unselected_agent_and_its_extra_paths_never_start() { let daemon = spawn_daemon(&s); // health, hook-activity, claude and claude-subagent with one extra path - // each, two Hermes profile databases, and the two delivery tasks. Logged by - // the supervisor before any task runs, so it is the whole deployment. - daemon.wait_for("collector started tasks=10", 1); + // each, two Hermes profile databases, and the three delivery tasks (the + // additive SDK OTLP spool included). Logged by the supervisor before any + // task runs, so it is the whole deployment. + daemon.wait_for("collector started tasks=11", 1); daemon.wait_for(&file_source("claude", 0, 7), 2); daemon.wait_for(&file_source("claude-subagent", 0, 7), 2); daemon.wait_for("sqlite source started source=\"hermes\"", 2); @@ -370,7 +371,7 @@ fn an_unknown_selected_name_is_warned_about_on_every_build_even_without_sessions let warning = "[failproofaid] agents.selected names claud, which has no collector source; \ nothing is collected for it. Known: claude, codex, copilot, openclaw, pi, \ factory, antigravity, cursor, goose, opencode, devin, hermes"; - daemon.wait_for("collector started tasks=4", 1); + daemon.wait_for("collector started tasks=5", 1); daemon.wait_for(warning, 1); // Once per build: a cycle builds again and says it again. @@ -392,7 +393,7 @@ fn changing_the_selection_mid_run_cycles_the_right_sources() { "", )); let daemon = spawn_daemon(&s); - daemon.wait_for("collector started tasks=6", 1); + daemon.wait_for("collector started tasks=7", 1); daemon.wait_for(&file_source("claude", 0, 7), 1); daemon.wait_for(&file_source("claude-subagent", 0, 7), 1); daemon.assert_absent("source=\"codex\""); @@ -404,7 +405,7 @@ fn changing_the_selection_mid_run_cycles_the_right_sources() { "", )); daemon.wait_for("collector configuration changed; cycling the collector", 1); - daemon.wait_for("collector started tasks=5", 1); + daemon.wait_for("collector started tasks=6", 1); daemon.wait_for(&file_source("codex", 0, 7), 1); // The second deployment did not start claude again. assert_eq!( @@ -557,7 +558,7 @@ fn an_agent_re_added_on_a_running_daemon_never_resumes_its_stale_cursors() { s.cursors("codex", &[&stale]); let daemon = spawn_daemon(&s); - daemon.wait_for("collector started tasks=6", 1); + daemon.wait_for("collector started tasks=7", 1); let now = SystemTime::now(); s.request(&format!( From 76a749fdb43b858011e5f399ac30d948bdde6042 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:33:36 +0000 Subject: [PATCH 12/14] fix(otel): derive each status dot from its own state --- __tests__/hooks/otel-output.test.ts | 4 ++-- src/hooks/otel-output.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/__tests__/hooks/otel-output.test.ts b/__tests__/hooks/otel-output.test.ts index 187db6176..97d75abc9 100644 --- a/__tests__/hooks/otel-output.test.ts +++ b/__tests__/hooks/otel-output.test.ts @@ -8,14 +8,14 @@ describe("OTEL screen language", () => { "Org OTEL ingest: on", "Claude Code: on by failproofai · destination: Cloud", "Codex: on, not managed · destination: Cloud", - "Gemini CLI: off · destination: none", + "Gemini CLI: off · destination: none · org: on", "Copilot (VS Code): unreadable settings · destination: none", "Relay: listening on 127.0.0.1 · last upload: none", ] })).toEqual([ "● Org OTEL ingest: on", "● Claude Code: on by failproofai · destination: Cloud", "● Codex: on, not managed · destination: Cloud", - "○ Gemini CLI: off · destination: none", + "○ Gemini CLI: off · destination: none · org: on", "▲ Copilot (VS Code): unreadable settings · destination: none", "● Relay: listening on 127.0.0.1 · last upload: none", ]); diff --git a/src/hooks/otel-output.ts b/src/hooks/otel-output.ts index 82ce67dde..b8753328f 100644 --- a/src/hooks/otel-output.ts +++ b/src/hooks/otel-output.ts @@ -6,7 +6,7 @@ export function otelReportLines(verb: string, result: OtelResult): string[] { return result.lines.flatMap(line => { if (verb === "status") { const glyph = line.includes("unreadable settings") ? "▲" : - /: on\b|: listening\b/.test(line) ? "●" : "○"; + /^[^:]+: (?:on\b|listening\b)/.test(line) ? "●" : "○"; return [`${glyph} ${line}`]; } if (line === OTEL_ORG_OFF) return [ From 52020afcd889e11010457ed6ac9a6ad4ba344fab Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Mon, 12 Oct 2026 21:51:55 +0530 Subject: [PATCH 13/14] fix(otel): keep OpenTelemetry out of `failproofai config` OTEL is opt-in, per agent, through `failproofai otel enable `. Setup asked "Also send OpenTelemetry from agents?" after the agents step and then a second picker, on every interactive Cloud setup. That put an optional feature in the one flow every user runs, and it was the only question there that wasn't about the machine itself. Removed the step and its apply block, `chooseWizardOtel` and `OTEL_WIZARD_QUESTION`, and the HOME/selectOne plumbing only it used. The wizard is back to the four steps of config v2. Tests: one test now covers typed, unattended, token, oss and saved setup. It asserts that setup asks no extra question and never calls the OTEL command. A source check pins that the wizard doesn't import otel-cli. Checked: tsc and lint are clean. The full unit suite passes except two VS Code detection tests in otel.test.ts, which fail identically on the branch without this change. --- CHANGELOG.md | 4 +- __tests__/hooks/configure-wizard.test.ts | 81 ++++++------------------ __tests__/hooks/otel.test.ts | 12 ++-- src/hooks/configure-wizard.ts | 61 ++---------------- src/hooks/otel-cli.ts | 6 -- 5 files changed, 31 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b75aee2..53dee9b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (#880). - Add optional TypeScript and Python OpenTelemetry span exporters with durable OTLP/JSON spool delivery and no hard runtime dependencies (#880). -- Port OpenTelemetry help, setup and the optional wizard step into the new TUI and config v2, retaining per-agent transcript overrides and reversible settings (#880). +- Port OpenTelemetry help and setup into the new TUI, retaining per-agent transcript overrides and reversible settings. OpenTelemetry stays out of `failproofai config`: it is opt-in, per agent, through `failproofai otel enable ` (#880). - New terminal look for the CLI, step one: the palette gains the redesign's label grey, progress-track grey and a red for failures. Descriptions keep the terminal's own colour, and the greys fall back to the dim attribute below 24-bit, so every screen stays readable on light themes (#881) - Pickers in the new look: an UPPERCASE heading with a live "n of m selected", a `›` cursor and `■ □` boxes, twelve rows before scrolling, plain `a` to toggle all, and the key hints at the foot. The `policies -i` agent menu now uses the shared picker (#881) - Every `--help` page in the new look, cut to usage, options and examples, with every real flag and its real default. The index opens with three getting-started steps, then lists every command by what it is for. `policies add|remove|show --help` now prints the one policies page (#881) @@ -33,7 +33,7 @@ ### Fixes - Preserve Codex OTEL header comments, omit local Claude auth-header settings, and require an executable or Copilot extension before configuring VS Code (#880). -- Keep Python OTEL imports lazy, honor wizard home isolation, label direct loopback destinations correctly, omit local Codex auth headers, and scope help options to each verb (#880). +- Keep Python OTEL imports lazy, label direct loopback destinations correctly, omit local Codex auth headers, and scope help options to each verb (#880). - `failproofai policies` showed nothing for a machine still on the pre-pack builtins, while those builtins were enforcing. It now says how many under BUILT IN, with the command that moves them into a pack (#881) - `failproofai config --disconnect` told you to restart the daemon to stop it sending. It stops on its own within a few seconds, because it polls the credential, and now says so (#881) - A config change that landed while the daemon was restarting its collector after a backfill was recorded as already running and never applied. `failproofai config` re-adding an agent hits exactly that, writing its backfill request and then the selection, so the agent stayed off until some unrelated edit (#881) diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 609a55799..ca78a4b71 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -728,66 +728,27 @@ describe("connect step", () => { }); }); - it("passes the resolved HOME into OTEL enable after the optional wizard step", async () => { - const enable = vi.spyOn(otelCli, "runOtelCommand").mockResolvedValue({ - lines: ["Gemini CLI OTEL enabled"], exitCode: 0, - }); - drive({ cloud: { kind: "typed", key: "a-real-looking-key" }, agents: ["claude"] }); - vi.mocked(selectOne).mockResolvedValueOnce(true); - vi.mocked(multiSelect).mockResolvedValueOnce(["gemini"]); - try { - const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(true); - expect(result.connected).toBe(true); - expect(enable).toHaveBeenCalledExactlyOnceWith( - ["enable", "gemini"], - expect.objectContaining({ home: fileHome }), - ); - } finally { - enable.mockRestore(); - } - }); - - it("offers OTEL after agent selection, defaults No, and has no retired review step", async () => { - drive({ cloud: { kind: "typed", key: "a-real-looking-key" }, agents: ["claude"] }); - const enable = vi.spyOn(otelCli, "runOtelCommand"); - const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(true); - expect(selectOne).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ - message: otelCli.OTEL_WIZARD_QUESTION, - choices: [ - expect.objectContaining({ label: "No", value: false }), - expect.objectContaining({ label: "Yes", value: true }), - ], - })); - expect(vi.mocked(multiSelect).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(selectOne).mock.invocationCallOrder[0]!, - ); - expect(enable).not.toHaveBeenCalled(); - }); - - it.each(["choice", "agents"])("Esc at the OTEL %s step changes nothing before apply", async step => { - drive({ cloud: { kind: "typed", key: "a-real-looking-key" }, agents: ["claude"] }); - vi.mocked(selectOne).mockResolvedValueOnce(step === "choice" ? null : true); - if (step === "agents") vi.mocked(multiSelect).mockResolvedValueOnce(null); - const result = await runConfigureWizard(ttyIO()); - expect(result).toEqual({ applied: false, abort: "cancelled" }); - expect(installHooks).not.toHaveBeenCalled(); - expect(connectToCloud).not.toHaveBeenCalled(); - expect(existsSync(fpConfigFile())).toBe(false); - expect(existsSync(credentialsFile())).toBe(false); - }); - - it.each(["unattended", "token", "oss", "saved"])("does not imply OTEL for %s setup", async mode => { - if (mode === "saved") { - writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "saved-key-3f2a" }); - drive({ cloud: { kind: "saved", verified: true } }); - } - const result = await runConfigureWizard(mode === "unattended" ? headlessIO() : ttyIO(), - mode === "token" ? { token: "a-real-looking-key" } : mode === "oss" ? { oss: true } : {}); - expect(result.applied).toBe(true); - expect(selectOne).not.toHaveBeenCalled(); - }); + it.each(["typed", "unattended", "token", "oss", "saved"])( + "never asks about OpenTelemetry or turns it on (%s setup): it is opt-in through `failproofai otel`", + async mode => { + const enable = vi.spyOn(otelCli, "runOtelCommand"); + if (mode === "saved") { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "saved-key-3f2a" }); + drive({ cloud: { kind: "saved", verified: true } }); + } else if (mode === "typed") { + drive({ cloud: { kind: "typed", key: "a-real-looking-key" }, agents: ["claude"] }); + } + try { + const result = await runConfigureWizard(mode === "unattended" ? headlessIO() : ttyIO(), + mode === "token" ? { token: "a-real-looking-key" } : mode === "oss" ? { oss: true } : {}); + expect(result.applied).toBe(true); + expect(selectOne).not.toHaveBeenCalled(); + expect(enable).not.toHaveBeenCalled(); + } finally { + enable.mockRestore(); + } + }, + ); it("checks a typed key without writing it, and reports a refusal", async () => { vi.mocked(validateIngestKey).mockResolvedValue({ ok: false, reason: "the server rejected that key (401)" }); diff --git a/__tests__/hooks/otel.test.ts b/__tests__/hooks/otel.test.ts index dcf9d7d59..e8cc787d2 100644 --- a/__tests__/hooks/otel.test.ts +++ b/__tests__/hooks/otel.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { spawnSync } from "node:child_process"; -import { runOtelCommand, chooseWizardOtel, OTEL_ORG_OFF, OTEL_WIZARD_QUESTION } from "../../src/hooks/otel-cli"; +import { runOtelCommand, OTEL_ORG_OFF } from "../../src/hooks/otel-cli"; import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, vscodeInstalled, agentOtelWrites } from "../../src/hooks/otel-writers"; import { configFile, credentialsFile, otelAgentStateFile, otelAgentStateDir, otlpSpoolDir, resettablePaths } from "../../src/hooks/fp-home"; import { readConfig, updateConfig } from "../../src/hooks/fp-config"; @@ -349,14 +349,10 @@ describe("opt-in, one path and status", () => { expect(existsSync(agentConfigPath("claude", home))).toBe(false); }); - it("wizard question defaults No and uses the shared enable entry point", async () => { - expect(await chooseWizardOtel()).toBe(false); - const choose = vi.fn(async () => true); - expect(await chooseWizardOtel(choose)).toBe(true); - expect(choose).toHaveBeenCalledWith(OTEL_WIZARD_QUESTION); + it("is not part of `failproofai config`: setup never imports the OTEL command", () => { const source = readFileSync(join(__dirname, "../../src/hooks/configure-wizard.ts"), "utf8"); - expect(source).toContain('runOtelCommand(["enable", agent]'); - expect(source).toContain("!unattended && !preAnswered"); + expect(source).not.toContain('from "./otel-cli"'); + expect(source).not.toContain('import("./otel-cli")'); }); it("analytics properties contain only agent, local and content", async () => { diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index 54083a21f..cc784d5e6 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -1,14 +1,13 @@ /** * `failproofai config` — the interactive setup launcher. * - * Five steps, in the order the machine needs them: + * Four steps, in the order the machine needs them: * * 1. Daemon — REQUIRED. Asks for sudo first, on a clean terminal. * 2. Connect — one masked API-key field; Tab for open source. * 3. Agents — which agents failproofai traces: hooks, collection and * backfill all follow this one saved list. - * 4. OTEL — optional, default No, on interactive Cloud setup only. - * 5. Done — applied after the last answer; there is no separate review. + * 4. Done — applied after the last answer; there is no separate review. * Esc at any step before that changes nothing. * * ## Two ordering rules that are not cosmetic @@ -31,7 +30,6 @@ import { dirname, resolve, sep } from "node:path"; import { CORE_SOURCE } from "./pack-store"; import { - selectOne, collapseAfter, colorsEnabled, multiSelect, @@ -709,7 +707,6 @@ export async function runConfigureWizard( const stdin: TTYIn = io.stdin ?? process.stdin; const stdout: TTYOut = io.stdout ?? process.stdout; const cwd = process.cwd(); - const home = process.env.HOME || homedir(); const started = Date.now(); // No terminal means no questions — not a refusal. // @@ -1068,45 +1065,12 @@ export async function runConfigureWizard( if (picked === null) return cancel(); selected = plan.order.filter((id) => picked.includes(id)); } - // Optional OTEL choice is never implied by unattended setup or a pasted key. - const { chooseWizardOtel, runOtelCommand } = await import("./otel-cli"); - let otelCancelled = false; - const otelWanted = !unattended && !preAnswered && cloud.kind === "connect" && await chooseWizardOtel( - async message => { - const answer = await selectOne({ - message, - choices: [ - { label: "No", value: false, hint: "keep current telemetry" }, - { label: "Yes", value: true, hint: "choose OTEL agents" }, - ], - collapsed: value => [kit.head("OpenTelemetry"), kit.ok(value ? "Opting in to OTEL." : "Keeping current telemetry."), ""], - stdin, stdout, - }); - otelCancelled = answer === null; - return answer === true; - }, - ); - if (otelCancelled) return cancel(); - let otelAgents: string[] = []; - if (otelWanted) { - const picked = await multiSelect({ - message: "Which agents should send OpenTelemetry?", - choices: [ - { label: "Gemini CLI", value: "gemini", checked: false }, - { label: "Copilot (VS Code)", value: "copilot", checked: false }, - { label: "Claude Code", value: "claude", checked: false }, - { label: "Codex", value: "codex", checked: false }, - ], - stdin, stdout, - }); - if (picked === null) return cancel(); - otelAgents = picked; - } const { added, removed } = agentChanges(plan, selected); // ── Apply ───────────────────────────────────────────────────────────────── // - // The agents step (or the optional OTEL choice) was the last decision. + // The agents step was the last decision. OpenTelemetry is not part of setup: + // it is opt-in, per agent, through `failproofai otel enable `. // ORDER MATTERS here: // // 1. The daemon first, because setup requires it: if it cannot be installed @@ -1321,23 +1285,6 @@ export async function runConfigureWizard( updateConfig({ mode: "cloud" }); } - if (otelWanted && connected) { - for (const agent of otelAgents) { - const result = await runOtelCommand(["enable", agent], { - home, - onEvent: emit, - confirm: async message => await selectOne({ - message, - choices: [ - { label: "No", value: false, hint: "keep transcript upload" }, - { label: "Yes", value: true, hint: "switch to OTEL" }, - ], - stdin, stdout, - }) === true, - }); - done.push(...result.lines.map(line => result.exitCode === 0 ? kit.ok(line) : kit.fail(line))); - } - } await applied; // Only now — a completed apply — is the launcher considered "seen", so // first-run onboarding stops offering itself on every command. diff --git a/src/hooks/otel-cli.ts b/src/hooks/otel-cli.ts index f4255c090..aa11a344b 100644 --- a/src/hooks/otel-cli.ts +++ b/src/hooks/otel-cli.ts @@ -21,7 +21,6 @@ import { } from "./otel-writers"; export const OTEL_ORG_OFF = "OTEL ingest is off for your org. An admin can turn it on in Settings → OpenTelemetry."; -export const OTEL_WIZARD_QUESTION = "Also send OpenTelemetry from agents? (Gemini CLI, Copilot, or switch Claude Code / Codex)"; export interface OtelResult { lines: string[]; exitCode: number; @@ -115,11 +114,6 @@ async function defaultConfirm(question: string): Promise { ], }) === true; } -export async function chooseWizardOtel( - choose: (question: string) => Promise = defaultConfirm, -): Promise { - return choose(OTEL_WIZARD_QUESTION); -} function restoreAgent(agent: OtelAgent, raw: JsonObject): void { const all = records(raw); const record = all[agent]; From 0ffc7c15dea35d2fdf66865a969179fff676ab4d Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Mon, 12 Oct 2026 21:53:50 +0530 Subject: [PATCH 14/14] test(otel): VS Code detection tests no longer depend on the machine Two tests emptied PATH to simulate "no VS Code", but vscodeInstalled also checks fixed system paths (/usr/share/code/code, /Applications/...). On any developer machine with VS Code installed they failed; CI passed only because its runners have no VS Code. The fixed locations move behind `vscodeProbe.appPaths`, a small seam the tests stub, with no change in behaviour. A new test pins that a fixed install location is still detected with nothing on PATH, and that the Linux list is unchanged. Checked: the full unit suite (8363) and e2e (359) pass on a machine with /usr/bin/code and /usr/share/code/code present. tsc and lint are clean. The audit fingerprint is unchanged. --- __tests__/hooks/otel.test.ts | 24 +++++++++++++++++++++--- src/hooks/otel-writers.ts | 28 +++++++++++++++++++--------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/__tests__/hooks/otel.test.ts b/__tests__/hooks/otel.test.ts index e8cc787d2..0ce3a6101 100644 --- a/__tests__/hooks/otel.test.ts +++ b/__tests__/hooks/otel.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { spawnSync } from "node:child_process"; import { runOtelCommand, OTEL_ORG_OFF } from "../../src/hooks/otel-cli"; -import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, vscodeInstalled, agentOtelWrites } from "../../src/hooks/otel-writers"; +import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, vscodeInstalled, vscodeProbe, agentOtelWrites } from "../../src/hooks/otel-writers"; import { configFile, credentialsFile, otelAgentStateFile, otelAgentStateDir, otlpSpoolDir, resettablePaths } from "../../src/hooks/fp-home"; import { readConfig, updateConfig } from "../../src/hooks/fp-config"; import { agentSessionsEnabled } from "../../src/hooks/collector-config"; @@ -195,13 +195,15 @@ describe("managed OTEL settings", () => { it("skips VS Code when absent and resolves macOS, Linux and Windows user paths", async () => { const savedPath = process.env.PATH; process.env.PATH = ""; + // System-wide installs (/usr/share/code/code…) would otherwise answer for a dev machine with VS Code. + const appPaths = vi.spyOn(vscodeProbe, "appPaths").mockReturnValue([]); try { mkdirSync(dirname(agentConfigPath("copilot", home)), { recursive: true }); const before = readFileSync(configFile(), "utf8"); expect((await runOtelCommand(["enable", "copilot"], options())).lines).toEqual(["VS Code not found — skipping copilot"]); expect(readFileSync(configFile(), "utf8")).toBe(before); expect(vscodeInstalled(home)).toBe(false); - } finally { process.env.PATH = savedPath; } + } finally { process.env.PATH = savedPath; appPaths.mockRestore(); } expect(existsSync(agentConfigPath("copilot", home))).toBe(false); expect(vscodeSettingsPath(home, "darwin")).toContain("Library/Application Support/Code/User/settings.json"); expect(vscodeSettingsPath(home, "linux")).toContain(".config/Code/User/settings.json"); @@ -214,6 +216,8 @@ describe("managed OTEL settings", () => { it("detects an executable or Copilot extension, not an unrelated extension", () => { const savedPath = process.env.PATH; process.env.PATH = ""; + // System-wide installs (/usr/share/code/code…) would otherwise answer for a dev machine with VS Code. + const appPaths = vi.spyOn(vscodeProbe, "appPaths").mockReturnValue([]); try { mkdirSync(join(home, ".vscode/extensions/unrelated.extension-1.0"), { recursive: true }); expect(vscodeInstalled(home)).toBe(false); @@ -225,7 +229,21 @@ describe("managed OTEL settings", () => { writeFileSync(join(bin, "code-insiders"), "#!/bin/sh\n", { mode: 0o755 }); process.env.PATH = bin; expect(vscodeInstalled(home)).toBe(true); - } finally { process.env.PATH = savedPath; } + } finally { process.env.PATH = savedPath; appPaths.mockRestore(); } + }); + + it("detects VS Code at a fixed install location with nothing on PATH", () => { + const savedPath = process.env.PATH; + process.env.PATH = ""; + const app = join(home, "opt/visual-studio-code/code"); + mkdirSync(dirname(app), { recursive: true }); + writeFileSync(app, "#!/bin/sh\n", { mode: 0o755 }); + const appPaths = vi.spyOn(vscodeProbe, "appPaths").mockReturnValue([join(home, "missing/code"), app]); + try { + expect(vscodeInstalled(home, "linux")).toBe(true); + expect(appPaths).toHaveBeenCalledWith(home, "linux"); + } finally { process.env.PATH = savedPath; appPaths.mockRestore(); } + expect(vscodeProbe.appPaths(home, "linux")).toEqual(["/usr/share/code/code", "/opt/visual-studio-code/code"]); }); it("never overwrites an invalid config or creates a backup for it", async () => { diff --git a/src/hooks/otel-writers.ts b/src/hooks/otel-writers.ts index 5efdd6363..0f3c61e48 100644 --- a/src/hooks/otel-writers.ts +++ b/src/hooks/otel-writers.ts @@ -28,6 +28,24 @@ export function agentConfigPath(agent: OtelAgent, home = homedir()): string { resolve(home, `.${agent}`, agent === "codex" ? "config.toml" : "settings.json"); } +/** + * Fixed install locations checked after PATH. A mutable seam so tests can run + * on a machine that really has VS Code installed: these paths are system-wide, + * so no temp HOME or PATH hides them. + */ +export const vscodeProbe = { + appPaths(home: string, platform: NodeJS.Platform): string[] { + return platform === "darwin" ? [ + "/Applications/Visual Studio Code.app/Contents/MacOS/Electron", + "/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Electron", + resolve(home, "Applications/Visual Studio Code.app/Contents/MacOS/Electron"), + ] : platform === "win32" ? [ + resolve(process.env.LOCALAPPDATA ?? resolve(home, "AppData/Local"), "Programs/Microsoft VS Code/Code.exe"), + resolve(process.env.ProgramFiles ?? "C:/Program Files", "Microsoft VS Code/Code.exe"), + ] : ["/usr/share/code/code", "/opt/visual-studio-code/code"]; + }, +}; + export function vscodeInstalled(home = homedir(), platform = process.platform): boolean { const executable = (path: string) => { try { @@ -38,15 +56,7 @@ export function vscodeInstalled(home = homedir(), platform = process.platform): const names = platform === "win32" ? ["code.cmd", "code-insiders.cmd", "Code.exe", "Code - Insiders.exe"] : ["code", "code-insiders"]; if ((process.env.PATH ?? "").split(platform === "win32" ? ";" : ":") .some(dir => names.some(name => executable(resolve(dir, name))))) return true; - const apps = platform === "darwin" ? [ - "/Applications/Visual Studio Code.app/Contents/MacOS/Electron", - "/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Electron", - resolve(home, "Applications/Visual Studio Code.app/Contents/MacOS/Electron"), - ] : platform === "win32" ? [ - resolve(process.env.LOCALAPPDATA ?? resolve(home, "AppData/Local"), "Programs/Microsoft VS Code/Code.exe"), - resolve(process.env.ProgramFiles ?? "C:/Program Files", "Microsoft VS Code/Code.exe"), - ] : ["/usr/share/code/code", "/opt/visual-studio-code/code"]; - if (apps.some(executable)) return true; + if (vscodeProbe.appPaths(home, platform).some(executable)) return true; return [".vscode", ".vscode-insiders"].some(folder => { try { return readdirSync(resolve(home, folder, "extensions"), { withFileTypes: true })