Compare commits

...

380 Commits

Author SHA1 Message Date
Kit Langton 7266cb2023 docs(opencode): show before/after TS blocks for each Phase 2 sub-step 2026-05-07 16:37:41 -04:00
Kit Langton 48e11a3b93 docs(opencode): expand Phase 2 with concrete data models per import 2026-05-07 16:33:52 -04:00
Kit Langton b068917fd8 docs(opencode): rewrite migration plan as outline, lead with model handle phase 2026-05-07 16:30:34 -04:00
Kit Langton 7734339915 refactor(llm): align schema discriminator helpers 2026-05-07 16:10:24 -04:00
Kit Langton 15527ce2ce docs(opencode): add AI SDK → @opencode-ai/llm migration plan
Capture the assessment of how opencode integrates the AI SDK today and
the phased plan to replace it with @opencode-ai/llm behind a feature flag.

Sections:
- Today's architecture, including the trace of one streamText call and
  the existing native path's gate conditions
- Where the spaghetti actually lives (AI SDK type leakage in 11+ files,
  scattered provider-specific transforms, the provider/sdk/copilot/* fork,
  duplicated MessageV2 conversion)
- Target architecture (one flag, one decision point at layer construction)
- Phased migration: Decouple → Service swap → Native parity → Flag rollout
  → Delete AI SDK
- Suggested execution order, key files, risks and open questions
2026-05-07 16:08:47 -04:00
Kit Langton c4c60e76ae refactor(llm): polish openai-responses hosted tools and body schemas
Three small cleanups in the OpenAI Responses protocol:

- Unify `HOSTED_TOOL_NAMES` + `hostedToolInput` into one `HOSTED_TOOLS`
  record per tool: `{ name, input: (item) => unknown }`. Adding a new
  hosted tool is now a single entry instead of two parallel switches that
  must stay in sync.
- Tighten `isHostedToolItem`'s narrowing to include the `type` field, so
  callers know they're dealing with a known hosted-tool shape (not just
  "has an id"). Drives a cleaner `hostedToolEvents` signature.
- Split the body schema into a shared `OpenAIResponsesCoreFields` record
  used by both the HTTP body (adds `stream: true`) and the WebSocket
  `response.create` message (adds `type`). Removes the destructure-and-
  strip dance at schema definition time. Runtime conversion in
  `webSocketMessage` still strips `stream` because OpenAI's WebSocket API
  doesn't expect it on the wire.

Plus a tiny fix in bedrock-converse.ts: explicit `Route.model<BedrockConverseModelInput>`
type argument so the mapInput overload selects properly (was inferring
to the narrower `RouteModelInput`).
2026-05-07 13:15:16 -04:00
Kit Langton 797e203d1d docs(llm): refresh AGENTS.md, retire stale designs
Bring the package guide in line with everything that's landed (route
rename, body/event vocabulary, schema split, endpoint simplification,
WebSocket transport, AuthOptions.bearer, dispatched protocol step).

- Update AGENTS.md throughout: payload→body, chunk→event, processChunk→step,
  Endpoint.baseURL({...})→Endpoint.path(...), refreshed folder layout,
  refreshed Routes / URL Construction / Provider Definitions sections.
- Fold HOUSE_STYLE.md (protocol file shape, rules, review checklist) into
  AGENTS.md as a "Protocol File Style" section.
- Delete the four DESIGN.*.md proposals that are fully implemented:
  routes-protocol-transport, websocket-transport, http-retry, model-options.
- Delete TOUR.md — it had grown into a 700-line narrative walkthrough that
  duplicated AGENTS.md with stale vocabulary. example/tutorial.ts is the
  canonical reading path now.
2026-05-07 13:12:19 -04:00
Kit Langton d0b9345bfd refactor(llm): move websocket recorder into http-recorder 2026-05-07 13:06:03 -04:00
Kit Langton c5e20033ec refactor(llm): collapse Endpoint to path-only; require ModelRef.baseURL
Push URL host knowledge from `Endpoint` (route layer) up to `model.baseURL`
(provider helper layer). The route just composes a path onto whatever
host the model already carries.

Endpoint:
- `Endpoint<Body>` shrinks to `{ path }`. No more `default`, `baseURL`,
  or `required` fallback fields.
- `Endpoint.path(value)` replaces the old `Endpoint.baseURL({...})` factory.
- `Endpoint.render()` is now sync — `${model.baseURL}${path}` plus query
  params. No fallback chain, no Effect wrapper.

ModelRef:
- `ModelRef.baseURL: Schema.String` (was optional). Every materialized
  model carries a host.
- `RouteModelInput.baseURL?: string` stays optional so route defaults
  can supply a canonical URL; routes without a default tighten it.

Provider helpers:
- Each protocol exports a `DEFAULT_BASE_URL` constant and bakes it into
  `route.defaults.baseURL`. Provider helpers don't need to set baseURL.
- Azure uses a new `AtLeastOne<T>` helper from `auth-options.ts` to
  require either `resourceName` or `baseURL` at the type level.
- Bedrock provider computes baseURL from `region` at construction time.
- OpenAI-compatible profiles now have required (not optional) `baseURL`
  in their type — all 9 already supplied one.
- The `defaultBaseURL: string | false` knob on protocol endpoint
  factories is gone.

Effects:
- Forgetting baseURL is now caught at compile time (TS) or model
  construction time (modelWithDefaults runtime check), not request time.
- `Endpoint.render` no longer needs Effect wrapping in the transport
  hot path.
2026-05-07 12:03:28 -04:00
Kit Langton 32ad9cbc8c refactor(llm): unify recorded cassettes 2026-05-07 11:39:11 -04:00
Kit Langton 454e08792a fix(opencode): catch up to llm route/protocol rename
Three small fixes for stale references after the recent
adapter→route rename and `model.protocol` field removal in
@opencode-ai/llm:

- Update three `@opencode-ai/llm/adapter` imports to `/route`.
- Switch `NATIVE_PROTOCOLS` filter on `model.protocol` to
  `NATIVE_ROUTES` on `model.route`.
- Refresh test fixtures: `protocol:` → `route:`, drop `apiKey:`
  / `adapter:` assertions that reference fields no longer on
  `ModelRef`, drop the `attachments` expectation that no longer
  has a source-side handler.
- Default `key: "test-key"` on `ProviderTest.info` so prepare()
  can resolve auth without per-test env setup.
2026-05-07 09:09:13 -04:00
Kit Langton 1a77519a23 refactor(llm): tighten transport, redaction, and protocol parsers
Mostly cleanup spread across the package:

- Extract a generic `WebSocketTransport.json` from the inline OpenAI Responses
  WebSocket transport so the same shape can be reused for other providers.
  WebSocket open path now also responds to scope abort.
- Drop the `RouteContext` / `TransportContext` wrappers; the only field was
  `request`, so transports and routes now take `LLMRequest` directly.
- Tighten registry semantics: registering a duplicate route id throws instead
  of silently keeping the first.
- Bedrock media: dispatch by MIME table so unknown formats fail with a clear
  error instead of degrading into a malformed document block.
- Anthropic Messages: refactor `step` from one long if-chain into named
  per-event handlers (`onMessageStart`, `onContentBlockStart`, etc.) dispatched
  from a small top-level switch. Also rename the one-off `anthropicString`
  accessor to `signatureFromMetadata`.
- Executor: consolidate the duplicated sensitive-name regexes into a single
  `SENSITIVE_NAME_SOURCE` plus named JSON/query field patterns.
2026-05-07 00:05:02 -04:00
Kit Langton 24425f29c9 refactor(llm): split schema, share provider auth, tighten openrouter
Three small cleanups:

- Split the 929-line `schema.ts` into `schema/{ids,options,messages,events,errors}.ts`
  with an `index.ts` barrel. No consumer changes — the barrel preserves all
  existing imports.
- Add `AuthOptions.bearer(options, envVar)` so providers that follow the
  apiKey-or-env bearer pattern (openai, xai) collapse their local auth
  factories to a one-liner.
- Replace `...OpenAIChat.protocol` spread with explicit `stream: ...protocol.stream`
  in openrouter so a new top-level field on OpenAIChat can't silently leak in.
2026-05-06 23:45:26 -04:00
Kit Langton 9a29f079ef refactor(llm): rename payload→body and chunk→event in protocol shape
Group Protocol fields by direction: `body: { schema, from }` for the
request side and `stream: { event, initial, step, terminal, onHalt }`
for the response side. Rename `Protocol.define`→`Protocol.make` and
`Protocol.jsonChunk`→`Protocol.jsonEvent` to match the rest of the
codebase. Type aliases follow: `*Payload`→`*Body`, `*Chunk`→`*Event`.
2026-05-06 23:02:31 -04:00
Kit Langton 9980dd6247 refactor(llm): add route derivation 2026-05-06 22:44:25 -04:00
Kit Langton edda03a3d1 refactor(llm): rename adapters to routes 2026-05-06 22:19:28 -04:00
Kit Langton a0adbfc78d feat(llm): add responses websocket transport 2026-05-06 21:48:41 -04:00
Kit Langton 74052f89e8 core: standardize content validation errors across LLM providers
Users now see consistent, descriptive error messages when attempting
to use unsupported content types (like media or reasoning) with LLM
providers that don't support them. Instead of generic or inconsistent
error messages, each error clearly states which provider, message role,
and content types are actually supported.
2026-05-06 20:53:43 -04:00
Kit Langton 25173bfb4e refactor(llm): inline auth secret types 2026-05-06 20:52:58 -04:00
Kit Langton 76140b3da6 refactor(llm): move tool execution onto client 2026-05-06 20:51:11 -04:00
Kit Langton 2fe21224ac refactor(llm): share tool choice lowering 2026-05-06 20:40:47 -04:00
Kit Langton 8cdb7233b3 feat(llm): expose xai api choices 2026-05-06 19:53:20 -04:00
Kit Langton 67f97690e6 refactor(llm): make xai responses canonical 2026-05-06 18:08:34 -04:00
Kit Langton 7df7583a50 docs(llm): document provider definitions 2026-05-06 18:03:55 -04:00
Kit Langton b6b3d2d127 test(llm): tighten provider definition types 2026-05-06 17:42:01 -04:00
Kit Langton 9d89d663f6 refactor(llm): standardize provider ids 2026-05-06 17:35:52 -04:00
Kit Langton 0e3d2ef017 refactor(llm): keep adapters out of provider definitions 2026-05-06 17:34:20 -04:00
Kit Langton 56ebef0927 feat(llm): add provider definitions 2026-05-06 17:04:42 -04:00
Kit Langton 73326f5125 feat(llm): preserve provider diagnostics 2026-05-06 15:54:30 -04:00
Kit Langton 780a5d6d58 test(llm): cover provider auth option types 2026-05-06 15:49:01 -04:00
Kit Langton 376c78fd61 refactor(llm): remove trivial model input aliases 2026-05-06 15:46:51 -04:00
Kit Langton fe9b5cf1fb feat(llm): add composable auth 2026-05-06 15:34:35 -04:00
Kit Langton 02b1d68963 refactor(llm): centralize adapter policy helpers 2026-05-06 14:03:07 -04:00
Kit Langton 9c93840a38 fix(llm): keep local refactor coherent after merge 2026-05-06 13:51:38 -04:00
Kit Langton 8b8a09c59f Merge remote-tracking branch 'origin/llm-core-patch-api' into llm-core-patch-api
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/llm/AGENTS.md
#	packages/llm/package.json
#	packages/llm/script/recording-cost-report.ts
#	packages/llm/script/setup-recording-env.ts
#	packages/llm/src/adapter.ts
#	packages/llm/src/index.ts
#	packages/llm/src/llm.ts
#	packages/llm/src/patch.ts
#	packages/llm/src/protocols/anthropic-messages.ts
#	packages/llm/src/protocols/openai-responses.ts
#	packages/llm/src/provider/bedrock-converse.ts
#	packages/llm/src/provider/openai-compatible-chat.ts
#	packages/llm/src/provider/openai-compatible-family.ts
#	packages/llm/src/provider/xai.ts
#	packages/llm/src/schema.ts
#	packages/llm/src/tool-runtime.ts
#	packages/llm/src/tool.ts
#	packages/llm/test/adapter.test.ts
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/xai-grok-4-3-drives-a-tool-loop.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/xai-streams-text.json
#	packages/llm/test/fixtures/recordings/openai-compatible-chat/xai-streams-tool-call.json
#	packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json
#	packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json
#	packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json
#	packages/llm/test/provider-resolver.test.ts
#	packages/llm/test/provider/anthropic-messages.recorded.test.ts
#	packages/llm/test/provider/bedrock-converse.test.ts
#	packages/llm/test/provider/gemini.recorded.test.ts
#	packages/llm/test/provider/gemini.test.ts
#	packages/llm/test/provider/openai-chat-tool-loop.recorded.test.ts
#	packages/llm/test/provider/openai-compatible-chat.recorded.test.ts
#	packages/llm/test/provider/openai-responses.recorded.test.ts
#	packages/llm/test/provider/openai-responses.test.ts
#	packages/llm/test/recorded-scenarios.ts
#	packages/llm/test/recorded-test.ts
#	packages/opencode/src/provider/llm-bridge.ts
#	packages/opencode/src/session/llm-native-tools.ts
#	packages/opencode/src/session/llm.ts
#	packages/opencode/test/provider/llm-bridge.test.ts
#	packages/opencode/test/session/llm-native-stream.test.ts
2026-05-06 13:48:44 -04:00
Kit Langton 0b8040a3be refactor(llm): move system helpers onto schema 2026-05-06 13:30:56 -04:00
Kit Langton c99e278e28 refactor(llm): tighten helper and diagnostic surfaces 2026-05-06 13:28:49 -04:00
Kit Langton 9fc1d154c4 refactor(llm): tighten runtime service boundaries 2026-05-06 11:47:23 -04:00
Kit Langton 7b4f436fc2 refactor(llm): collapse client adapter injection 2026-05-06 11:08:31 -04:00
Kit Langton 1808fe8628 refactor(llm): clarify public module surface 2026-05-06 09:30:01 -04:00
Kit Langton cfe8fdb149 refactor(llm): resolve adapters from registry 2026-05-05 21:33:03 -04:00
Kit Langton 89c65949c2 refactor(llm): remove transform pipeline 2026-05-05 20:18:15 -04:00
Kit Langton d62bede2f7 test(llm): cover public export surface 2026-05-05 18:05:46 -04:00
Kit Langton b3568ab0f0 refactor(llm): replace patches with transforms 2026-05-05 17:58:58 -04:00
Kit Langton 172c382f00 refactor(llm): clarify adapter payload flow 2026-05-05 17:50:28 -04:00
Kit Langton 4daac79cf3 refactor(llm): split providers and protocols 2026-05-05 16:58:20 -04:00
Kit Langton e8d703108d refactor(llm): simplify provider protocol wiring 2026-05-05 16:53:27 -04:00
Kit Langton 8523299069 refactor(llm): simplify provider payload routing 2026-05-05 16:48:23 -04:00
Kit Langton c4e0972ab1 refactor(llm): share provider schema helpers 2026-05-05 16:45:20 -04:00
Kit Langton 11b892d2db refactor(llm): simplify patch pipeline 2026-05-05 16:21:53 -04:00
Kit Langton 678c40589f test(llm): restore recorded provider coverage 2026-05-05 16:15:38 -04:00
Kit Langton 6e2dc338e2 docs(llm): remove resolver references 2026-05-05 16:15:10 -04:00
Kit Langton 125b8b2e59 refactor(llm): validate patched targets from schema 2026-05-05 16:10:17 -04:00
Kit Langton 96aace5cfc refactor(llm): extract patch pipeline 2026-05-05 16:09:00 -04:00
Kit Langton f2d7fe350a chore(llm): checkpoint provider patch work 2026-05-05 15:56:57 -04:00
Kit Langton 908cb82265 fix(llm): set xai responses base url 2026-05-05 15:45:31 -04:00
Kit Langton b8523da524 refactor(llm): simplify adapter patch extension 2026-05-05 15:43:40 -04:00
Kit Langton e25906c6d2 refactor(llm): tighten adapter construction API 2026-05-05 15:43:14 -04:00
Kit Langton fce1c45bde test(llm): split recorded provider and prefix filters 2026-05-05 15:42:14 -04:00
Kit Langton d84da53742 test(llm): tighten recorded test assertions 2026-05-05 15:39:30 -04:00
Kit Langton b6fb6ac92e test(llm): summarize recorded stream events 2026-05-05 15:33:33 -04:00
Kit Langton 89922be658 refactor(llm): consolidate compatible provider profiles 2026-05-05 15:19:17 -04:00
Kit Langton 6242048dbd refactor(llm): streamline adapter model handles 2026-05-05 15:05:45 -04:00
Kit Langton 3765dde624 refactor(llm): simplify protocol composition API 2026-05-05 14:35:08 -04:00
Kit Langton 40819e1865 refactor(http-recorder): extract cassette service 2026-05-03 20:12:01 -04:00
Kit Langton 4b54bc38f1 refactor(http-recorder): make record mode explicit 2026-05-03 17:55:06 -04:00
Kit Langton 73a9372e7a test(llm): add gemini golden loop coverage 2026-05-03 17:30:58 -04:00
Kit Langton bc20399f6e test(llm): add groq recordings and cost report 2026-05-03 16:25:48 -04:00
Kit Langton 039c0506cf test(llm): expand flagship recorded coverage 2026-05-03 16:03:36 -04:00
Kit Langton 49340f517c test(llm): simplify golden tool loop scenarios 2026-05-03 15:29:25 -04:00
Kit Langton db74983861 test(llm): add openrouter golden tool loops 2026-05-03 14:44:49 -04:00
Kit Langton 3cae82f708 docs(llm): document provider recording strategy 2026-05-03 14:32:36 -04:00
Kit Langton 9e3868fed7 test(llm): add openrouter recorded coverage 2026-05-03 14:07:33 -04:00
Kit Langton a1c1d0766f chore(llm): use effect for recording env setup 2026-05-03 14:07:14 -04:00
Kit Langton b560f98962 chore(llm): improve recording env setup ux 2026-05-03 13:50:03 -04:00
Kit Langton cd866f32a1 chore(llm): add recording env setup 2026-05-03 13:40:53 -04:00
Kit Langton fea96490e3 fix(llm): expand compatible provider bridge 2026-05-03 13:11:45 -04:00
Kit Langton 6224ce84fd fix(llm): align provider tool semantics 2026-05-03 12:58:14 -04:00
Kit Langton c519ff2ce8 feat(llm): add consistent tagged checks 2026-05-03 12:49:56 -04:00
Kit Langton 6736923a35 fix(llm): preserve provider-native continuation state 2026-05-03 12:47:03 -04:00
Kit Langton eea8764ce1 simplify(llm): extract conversation semantics 2026-05-03 12:22:51 -04:00
Kit Langton 6e23e56a49 simplify(llm): trim native runtime overhead 2026-05-03 10:13:09 -04:00
Kit Langton b7930c75f4 simplify(llm): share tagged schema helper 2026-05-03 09:30:58 -04:00
Kit Langton 195e0bb0c3 simplify(llm): define events with schema tags 2026-05-03 09:14:37 -04:00
Kit Langton de79fd7306 fix(test): use instance helper in native fallback test 2026-05-03 08:50:17 -04:00
Kit Langton 1caeaf70bb Merge remote-tracking branch 'origin/dev' into HEAD 2026-05-03 08:48:44 -04:00
Shoubhit Dash d1f597b5b5 fix(vcs): avoid unbounded diff memory usage (#25581) 2026-05-03 17:49:46 +05:30
Dax Raad 8299fb3e2b ignore: remove triage-unassigned.ts script
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
docs-update / update-docs (push) Has been cancelled
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
stats / stats (push) Has been cancelled
close-issues / close (push) Has been cancelled
close-stale-prs / close-stale-prs (push) Has been cancelled
This script was used to batch-triage open GitHub issues without assignees.
Removing as the triage workflow has evolved and this batch approach is no longer needed.
2026-05-03 01:59:03 -04:00
Dax Raad 4f7f90133d ci: stop sending daily community recap notifications 2026-05-03 01:54:32 -04:00
Dax Raad b205e104f6 ci: remove vouch-based contributor filtering workflows
Removes the automated vouch system that filtered issues and PRs from non-vouched users. This simplifies the contribution process by removing the requirement for maintainers to manually vouch contributors before they can participate.
2026-05-03 01:54:32 -04:00
Dax Raad 252e2f98e6 ci: remove automatic labels from GitHub issue templates to allow manual triage 2026-05-03 01:54:32 -04:00
opencode-agent[bot] e2afdc1202 chore: generate 2026-05-03 05:22:22 +00:00
Dax Raad a08e4c9651 core: simplify triage workflow to focus on issue ownership
Switch triage agent to gpt-5.4-nano for faster issue assignment. Remove label
management from the triage tool so it only assigns owners based on team
ownership rules. This reduces noise in the issue tracker and ensures issues
get to the right team member immediately without unnecessary labels.

Update team structures to reflect current ownership and add script for
processing unassigned issues.
2026-05-03 01:21:17 -04:00
Dax Raad 7ccab8d272 core: update triage agent to use qwen3.6-plus model for improved response quality 2026-05-03 01:10:14 -04:00
Dax Raad fc57eb3b8e ci 2026-05-03 01:05:36 -04:00
Dax 9179bafd54 Add debug info command (#25550) 2026-05-03 05:04:52 +00:00
Kit Langton 2df8eda8a3 fix(cli): bridge Instance.current ALS in effectCmd handlers (regression from #25522) (#25546) 2026-05-03 04:24:33 +00:00
Kit Langton 9065d79a9a fix(llm): preserve native protocol state 2026-05-03 00:20:50 -04:00
Kit Langton bd32252a7e refactor(cli/providers): Stage 4 — drop inline AppRuntime.runPromise calls (#25532) 2026-05-02 23:42:40 -04:00
Kit Langton 1717d636a2 refactor(cli/mcp+agent): Stage 4 — drop AppRuntime.runPromise bridges (#25530) 2026-05-02 23:40:59 -04:00
Aiden Cline 8e016b4703 fix: regression w/ auth login where stderr was ignored instead of inherited (#25529) 2026-05-02 22:36:02 -05:00
opencode-agent[bot] b89d48a2a4 chore: update nix node_modules hashes 2026-05-03 03:25:46 +00:00
Dax 33312bfd1b fix(session): encode v2 session responses (#25528) 2026-05-03 03:24:46 +00:00
opencode-agent[bot] 3f1ce36418 chore: generate 2026-05-03 03:23:47 +00:00
Kit Langton 0e13279545 refactor(cli): convert agent / providers / mcp to effectCmd (#25525) 2026-05-02 23:22:44 -04:00
Kit Langton 5f03d892c0 fix(httpapi): pagination Link header echoes request host (#25527) 2026-05-02 23:19:33 -04:00
Kit Langton bdabb102fe refactor(cli/stats): Stage 4 — fully Effect-native body (#25523) 2026-05-02 23:08:26 -04:00
Kit Langton a79a6594b0 chore: bump Effect beta (#25524) 2026-05-02 23:08:13 -04:00
opencode-agent[bot] a3d282a4c2 chore: generate 2026-05-03 03:04:40 +00:00
Kit Langton db24f89313 refactor(cli): convert mcp list, auth, auth list, logout to effectCmd (#25521) 2026-05-03 03:03:32 +00:00
opencode-agent[bot] 31cb0bfa4f chore: generate 2026-05-03 02:54:20 +00:00
Kit Langton af9fdf0a1c refactor(cli): convert github subcommands to effectCmd (#25522) 2026-05-02 22:53:20 -04:00
Youssef Achy be88cd5cb9 chore(opencode): exclude .map files from CLI binary build (#25500) 2026-05-02 22:52:32 -04:00
Luke Parker b4cc7d13b6 fix(desktop): limit zoom handler to zoom keys (#25516) 2026-05-03 02:44:52 +00:00
Aiden Cline 0ba013f8de chore: rm log statement (#25470) 2026-05-02 21:43:48 -05:00
Kit Langton 0956b15c52 refactor(acp): drop async from synchronous ACP.init (#25520) 2026-05-02 22:38:44 -04:00
opencode-agent[bot] 61150f6391 chore: generate 2026-05-03 02:36:41 +00:00
Kit Langton 7409dcc6bd refactor(cli): convert run command to effectCmd (#25519) 2026-05-02 22:35:20 -04:00
Kit Langton 2829943ad1 refactor(cli): convert debug wait, agent list, acp to effectCmd (#25518) 2026-05-02 22:31:20 -04:00
Kit Langton c4311dda31 feat(cli): allow effectCmd instance to be a function of args (#25517) 2026-05-03 02:27:41 +00:00
Kit Langton ad05a46d74 refactor(lifecycle): bootstrap as pure orchestration (#25510) 2026-05-02 22:26:54 -04:00
opencode-agent[bot] a6cadba814 chore: generate 2026-05-03 02:10:52 +00:00
Dax a3bc5d35b0 Refactor v2 session events as schemas (#24512) 2026-05-02 22:09:48 -04:00
Kit Langton 1409a0715c refactor(cli): convert web + account to effectCmd (instance: false) (#25512) 2026-05-02 21:59:35 -04:00
Kit Langton e98c291866 feat(cli): add instance: false opt-out to effectCmd (#25507) 2026-05-03 01:44:06 +00:00
Kit Langton e709dc34fb feat: default HTTP API backend to on for dev/beta channels 2026-05-02 20:43:23 -04:00
opencode-agent[bot] 9293cddb3a chore: generate 2026-05-03 00:43:16 +00:00
Kit Langton 68b3448b09 refactor(cli): drop redundant explicit Effect.ensuring(store.dispose) (#25503) 2026-05-02 20:42:09 -04:00
opencode-agent[bot] 80f2b13a55 chore: generate 2026-05-03 00:40:21 +00:00
Kit Langton 7d91d3b1ed Normalize instance lifecycle wiring (#25501) 2026-05-02 20:39:20 -04:00
opencode-agent[bot] a6464062b7 chore: generate 2026-05-03 00:32:24 +00:00
Kit Langton fd01dc9c89 test(httpapi): add route exerciser 2026-05-02 20:31:21 -04:00
opencode-agent[bot] d10fb88b66 chore: generate 2026-05-03 00:10:53 +00:00
Luke Parker 6b68b1020e docs: clarify LSP and formatter opt-in config (#25502) 2026-05-03 00:09:50 +00:00
Kit Langton 85bb9007ba feat(cli): auto-dispose InstanceContext after effectCmd handlers (#25481) 2026-05-02 19:54:13 -04:00
opencode-agent[bot] 9bef88e3b0 chore: generate 2026-05-02 23:34:40 +00:00
Kit Langton f98053c34e fix(instance): run bootstrap from instance store (#25475) 2026-05-02 19:33:38 -04:00
opencode-agent[bot] 36007aecf4 chore: generate 2026-05-02 23:23:53 +00:00
Kit Langton 4de44bbbef refactor(cli): convert debug subcommands to effectCmd (#25479) 2026-05-02 19:22:51 -04:00
opencode-agent[bot] 9d03d4419e chore: generate 2026-05-02 23:20:15 +00:00
Kit Langton 7ab1c1c74a refactor(cli): convert debug agent command to effectCmd (#25485) 2026-05-02 19:19:06 -04:00
Luke Parker 3f459819ba feat: refactor bash tool with shell-aware prompts for bash, pwsh+powershell, and cmd (#20039) 2026-05-03 09:18:48 +10:00
Kit Langton 1986a6e817 refactor(cli): convert session subcommands to effectCmd (#25483) 2026-05-02 18:15:28 -04:00
opencode-agent[bot] dfe1325fca chore: generate 2026-05-02 22:02:14 +00:00
Kit Langton c1686c6ddc refactor(cli): convert stats command to effectCmd (#25474) 2026-05-02 18:01:06 -04:00
Kit Langton 79b6ce5db4 refactor(cli): convert import command to effectCmd (#25467) 2026-05-02 21:56:32 +00:00
Kit Langton 0c816eb4b1 refactor(cli): convert plugin command to effectCmd (#25473) 2026-05-02 17:55:13 -04:00
Kit Langton e318e173d8 refactor(cli): convert export command to effectCmd (#25471) 2026-05-02 17:45:41 -04:00
opencode-agent[bot] b314781a1a chore: generate 2026-05-02 21:02:46 +00:00
Kit Langton 8396d6b016 refactor(cli): convert pr command to effectCmd (#25465) 2026-05-02 17:01:46 -04:00
opencode 43e20874f4 sync release versions for v1.14.33 2026-05-02 19:53:06 +00:00
opencode-agent[bot] c444e971b0 chore: generate 2026-05-02 19:27:24 +00:00
HyeokjaeLee 430bde9e9b fix(instance): restore InstanceBootstrap init parameter for non-Effec… (#25449)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-05-02 15:26:30 -04:00
Kit Langton 05b82a6a30 refactor(cli): drop ModelsDev Promise compat shim (#25460) 2026-05-02 15:11:01 -04:00
Kit Langton 6cd02c05c2 fix(telemetry): emit Tool.execute span for MCP and plugin tools (#25452) 2026-05-02 14:49:56 -04:00
opencode-agent[bot] b3a7513765 chore: generate 2026-05-02 18:00:11 +00:00
Kit Langton f8738c9002 feat(models): effectify ModelsDev as Service (#25434) 2026-05-02 13:59:08 -04:00
Aiden Cline b460db15d7 tweak: allow read tool to accept offset of 0 (#25431) 2026-05-02 11:12:07 -05:00
opencode-agent[bot] ff4779ca11 chore: generate 2026-05-02 16:09:04 +00:00
Kit Langton 146ff8ad85 feat(cli): add effectCmd wrapper + convert models command (#25429) 2026-05-02 12:08:04 -04:00
OpeOginni 0d0ec7dc46 docs: CLI docs for current commands and flags (#25399) 2026-05-02 11:07:22 -05:00
Jérôme Benoit 1ea6e6cd4b fix(nix): remove stale packages/shared filter (#24930) 2026-05-02 10:49:51 -05:00
opencode-agent[bot] 96061222d2 chore: generate 2026-05-02 15:45:21 +00:00
Kit Langton 3b9155714d Delete Instance.dispose and Instance.reload (#25427) 2026-05-02 11:44:16 -04:00
opencode 7371db5cc6 sync release versions for v1.14.32 2026-05-02 15:34:12 +00:00
Kit Langton b09b7d28b8 refactor(instance-store): consolidate dispose helpers (#25424) 2026-05-02 11:21:40 -04:00
opencode-agent[bot] 31ed4602e1 chore: update nix node_modules hashes 2026-05-02 15:16:12 +00:00
Sebastian 6a76346734 upgrade opentui to 0.2.2 (#25420) 2026-05-02 15:01:53 +00:00
Kit Langton 78b3000031 fix(tui): keep shell-mode prompt editable (#25419) 2026-05-02 10:56:27 -04:00
Kit Langton 4c4860fb24 Replace Instance.disposeAll/load with fixture helper (#25418) 2026-05-02 10:56:15 -04:00
Kit Langton 5242a1c6b4 fix(httpapi): install Instance ALS for adapter Promise bridge (#25417) 2026-05-02 10:49:44 -04:00
Kit Langton 075f876e6f fix(httpapi): re-land workspace create payload accepts missing extra (#25412) 2026-05-02 09:35:39 -04:00
opencode-agent[bot] a849812e9f chore: generate 2026-05-02 13:09:14 +00:00
Kit Langton d99dde6306 Migrate test inits from Promise to Effect (#25377) 2026-05-02 09:07:59 -04:00
opencode-agent[bot] becf57ee6a chore: generate
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
docs-update / update-docs (push) Has been cancelled
deploy / deploy (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
stats / stats (push) Has been cancelled
daily-pr-recap / pr-recap (push) Has been cancelled
daily-issues-recap / daily-recap (push) Has been cancelled
close-issues / close (push) Has been cancelled
close-stale-prs / close-stale-prs (push) Has been cancelled
2026-05-02 04:03:59 +00:00
Kit Langton f33aec1139 Convert LoadInput.init to Effect + extract InstanceBootstrap as a Service (#25376) 2026-05-02 00:02:52 -04:00
Kit Langton 1571933096 Drop ALS fallbacks from containsPath and workspace routing (#25374) 2026-05-02 03:06:22 +00:00
Kit Langton 160928a9a9 Extract InstanceStore.provide helper (#25372) 2026-05-01 22:42:03 -04:00
opencode-agent[bot] d297c29f22 chore: generate 2026-05-02 02:19:48 +00:00
Kit Langton 0b498dd448 fix(httpapi): preserve OpenAPI parameter parity (#25291) 2026-05-01 22:18:52 -04:00
Kit Langton cec9c6122a Move instance loading into Effect service (#25277) 2026-05-01 22:18:06 -04:00
Zeke Sikelianos 51e310c9ce fix(read): prevent unsupported image formats from being sending to provider (#21114)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-01 18:14:22 -05:00
Kit Langton 046e459d65 fix(llm): map Responses tool calls finish reason 2026-05-01 17:47:46 -04:00
Kit Langton 652ef9c09a fix(llm): use Azure api-key auth for OpenAI adapters 2026-05-01 17:11:44 -04:00
Aiden Cline 478156456e core: fix npm package detection to properly handle cached directories without installed packages (#25354) 2026-05-01 15:49:14 -05:00
opencode-agent[bot] 6252412d94 chore: generate 2026-05-01 20:03:10 +00:00
Dax Raad c2609cbf04 core: allow agents to access global tmp directory without permission prompts
Agents can now create temporary files in the global tmp directory without
triggering external_directory permission prompts. This enables agents to
freely use temporary storage for intermediate files during builds and
other operations.
2026-05-01 15:35:45 -04:00
github-actions[bot] 2115df57bf Update VOUCHED list
https://github.com/anomalyco/opencode/issues/25288#issuecomment-4360290197
2026-05-01 16:16:45 +00:00
Aiden Cline 29ec07700c fix: bedrock reasoning issue (#25303) 2026-05-01 11:15:17 -05:00
Frank bcae852d28 zen: remove hardcoded safety identifier
deploy / deploy (push) Has been cancelled
2026-05-01 11:12:28 -04:00
Kit Langton e9d84c6db7 fix(llm): preserve native stream fallback parity 2026-05-01 08:54:05 -04:00
Kit Langton 116a5c2e74 docs(llm): document prepare<Target>, PreparedRequestOf, and LLMEvent.is.* in AGENTS.md 2026-05-01 08:12:38 -04:00
Kit Langton 8f338ef6dc simplify(llm): inline single-use llmEventIs const and drop redundant as const 2026-05-01 08:12:37 -04:00
Kit Langton 75f467bae3 feat(llm): expose PreparedRequestOf<Target> on LLMClient.prepare
LLMClient.prepare(request) returned a PreparedRequest with target: unknown.
Callers building debug UIs / request previews / plan rendering had to cast
target to the adapter's native shape at every read.

Adds PreparedRequestOf<Target> in schema and a generic Target = unknown
parameter on LLMClient.prepare so callers can opt in to a typed view:

  const prepared = yield* client.prepare<OpenAIChatTarget>(request)
  prepared.target.model           // typed
  prepared.target.messages         // typed

The runtime payload is unchanged — the adapter still emits target: unknown
and the consumer asserts the shape they expect from the configured adapter.
The cast lives at the public boundary in adapter.ts; everything else stays
honest about runtime types.

Existing callers without the type argument still get target: unknown and
nothing breaks. Test in openai-chat.test.ts proves the narrowing at the
type level.
2026-05-01 08:12:37 -04:00
Kit Langton f4de3e801e feat(llm): add LLMEvent.is.* camelCase narrowing helpers
Schema.toTaggedUnion('type') already provides LLMEvent.guards but uses
kebab-case bracket access (LLMEvent.guards['tool-call']). Adds an LLMEvent.is
namespace with camelCase aliases that delegate to the same guards, so
consumers can write events.filter(LLMEvent.is.toolCall) instead of
events.filter(LLMEvent.guards['tool-call']).

Migrated all callsites in src/llm.ts and the two test files for consistency.
LLMEvent.guards / .match / .cases / .isAnyOf remain available for callers
who want the Effect-canonical API.
2026-05-01 08:12:37 -04:00
Kit Langton a0165b2ae8 docs(llm): fix stale field names in ProtocolID comment and AGENTS.md code example 2026-05-01 08:12:37 -04:00
Kit Langton 9363c70acd simplify(llm): drop redundant auth: "key" from resolvers (it is the default) 2026-05-01 08:12:37 -04:00
Kit Langton 5ec2673af2 simplify(llm): inline resolveAdapter into compile 2026-05-01 08:12:37 -04:00
Kit Langton 8b414cdb5a refactor(llm): collapse ProviderAuth to 'key' | 'none'
After the auth-axis migration, the OpenCode bridge consults this enum
solely to decide whether to read `provider.key` and stamp it on
`model.apiKey`. The bearer / anthropic-api-key / google-api-key
distinctions used to control which header the bridge wrote; that is now
the adapter's Auth axis's job.

Three of four variants were write-only after the migration. Collapse to:

- 'key'  — provider needs an API key
- 'none' — provider does not (e.g. local)

Updated all six provider resolvers and the resolver test fixtures.
2026-05-01 08:12:37 -04:00
Kit Langton 49913ff041 refactor(llm): rename Adapter.define -> Adapter.unsafe; drop Adapter.compose
Two cleanups to make the adapter constructor surface honest about what is
canonical and what is an escape hatch:

- Adapter.compose existed to override pieces of an existing adapter, used
  by OpenAI-compatible Chat before the four-axis migration. After the
  migration nothing references it; OpenAI-compatible Chat composes via
  fromProtocol({ protocol: OpenAIChat.protocol, ... }) instead. Delete
  the function and its ComposeInput type.

- Adapter.define is the lower-level escape hatch for adapters whose
  behavior genuinely cannot fit the Protocol/Endpoint/Auth/Framing model.
  Its name implied it was the canonical entry point. Renamed to
  Adapter.unsafe so the four-axis Adapter.fromProtocol(...) reads as the
  obvious primary path and the escape hatch carries its escape semantics
  in its name.

Updated test fixtures in adapter.test.ts and the AGENTS.md guidance.
2026-05-01 08:12:37 -04:00
Kit Langton bb7f52b24d refactor(llm): remove ambiguous Adapter provider scoping field
The optional 'provider' field on Adapter / AdapterInput / FromProtocolInput
existed as a registry filter: requests with a different model.provider could
not find adapters that set it. After the four-axis migration no adapter
needed it (and an earlier pass removed it from the five migrated providers
because setting it broke session/llm-native tests).

Drop the field entirely and collapse the registry to a single-tier protocol
lookup. If a future deployment genuinely needs to be scoped (e.g. an
Azure-only OpenAI Responses adapter), reintroduce as 'scopedTo' with an
explicit name. Solve when needed, not before.

Also drops the test that exercised the now-removed two-tier lookup
('prefers provider-specific adapters over protocol fallbacks').
2026-05-01 08:12:37 -04:00
Kit Langton e7ff19bb5f simplify(llm): stringify endpoint URL once in Adapter.fromProtocol
url.toString() was called twice on the same URL object — once for auth
and once for jsonPost. Convert to string immediately and reuse.
2026-05-01 08:12:37 -04:00
Kit Langton bb859e2e2c simplify(llm): remove redundant queryParams from OpenAICompatibleChatModelInput
queryParams is now inherited from ModelInput (via ModelRef) after the
typed-field promotion. The explicit re-declaration was dead weight.
2026-05-01 08:12:37 -04:00
Kit Langton 61a18bdbd0 simplify(llm): fix stale model.native.queryParams references in docs
The commit that promoted queryParams to a typed ModelRef field updated
the implementation but left two JSDoc/doc references pointing at the old
model.native.queryParams path.
2026-05-01 08:12:37 -04:00
Kit Langton f86a6790a2 refactor(llm): move queryParams off model.native to typed field
Promotes queryParams to a first-class ModelRef field used by Endpoint.baseURL,
so deployment-level URL query params (Azure api-version, OpenAI-compatible
provider knobs) live in a typed home instead of an opaque `native` bag.

Also removes write-only dead fields from `native`:

- openaiCompatibleProvider (set by family helper, never read)
- opencodeProviderID, opencodeModelID (set by opencode bridge + native session
  builder, never read)
- npm (set by opencode bridge, never read)

After this commit `model.native` only carries genuinely provider-specific
opaque options that no other adapter cares about (Bedrock's aws_credentials
+ aws_region for SigV4). Drops the now-dead ProviderShared.queryParams
helper. Updates AGENTS.md doc on native is implicit through the new schema
JSDoc.
2026-05-01 08:12:37 -04:00
Kit Langton d8b9672234 simplify(llm): split Bedrock auth into bearer fast path + sigv4 gen
The two paths are independent: `model.apiKey` produces a synchronous
Bearer auth, while AWS credentials need an effectful sigv4 sign.
Hoist the bearer path out of `Effect.gen` and reuse `Auth.bearer`
directly, keeping the SigV4 path as a focused `Effect.gen` that owns
the credential lookup, signing, and header merge.

Inlines the now single-use `headersForSigning` and `signed` setup.
2026-05-01 08:12:37 -04:00
Kit Langton 042bf6c822 simplify(llm): default Adapter.fromProtocol auth to Auth.bearer
After the apiKey migration, every adapter explicitly specified `auth`,
and three of them (OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat)
all wrote `auth: Auth.bearer`. `Auth.bearer` is a no-op when
`model.apiKey` is unset, so making it the default is strictly safer than
the previous `Auth.passthrough` default — bearer-style adapters drop
their explicit `auth` line, and adapters that need a different scheme
opt out via `Auth.apiKeyHeader(...)` (Anthropic, Gemini) or a custom
`Auth` (Bedrock SigV4 + Bearer).

Update doc comments on `fromProtocol.auth`, `Auth` type, and
`packages/llm/AGENTS.md` to reflect the new default.
2026-05-01 08:12:37 -04:00
Kit Langton 5d08e28cd9 refactor(llm): move auth secret from headers onto ModelRef.apiKey
Add an optional `apiKey` field to `ModelRef` so authentication is no
longer baked into `model.headers` at construction time. Each provider
adapter now passes an `Auth` to `Adapter.fromProtocol` that reads
`request.model.apiKey` per request:

- OpenAI Chat / Responses / OpenAI-compatible Chat: `Auth.bearer`
- Anthropic Messages:  `Auth.apiKeyHeader("x-api-key")`
- Gemini:              `Auth.apiKeyHeader("x-goog-api-key")`
- Bedrock Converse:    custom auth that uses `apiKey` for Bearer auth
                       and falls back to SigV4 with AWS credentials

The `model()` constructors no longer fold the API key into
`model.headers`. The OpenCode bridge sets `apiKey` directly instead of
building auth headers via the now-deleted `authHeader` helper. Test
assertions move from `headers: { authorization: "Bearer ..." }` to
`apiKey: "..."`.
2026-05-01 08:12:37 -04:00
Kit Langton 4f294852a6 simplify(llm): share core between Auth.bearer and Auth.apiKeyHeader
Both helpers had the same shape: read `request.model.apiKey`, no-op if
absent, otherwise merge a one-key header object. Lift that into a tiny
`fromApiKey(from)` helper and define both in terms of it.

The public surface (`Auth.bearer`, `Auth.apiKeyHeader`) is unchanged.
2026-05-01 08:12:36 -04:00
Kit Langton a676b12b7b fix(llm): keep adapters provider-less by default
Removes the provider field from the five migrated Adapter.fromProtocol
calls. Setting provider scopes the adapter in the registry so requests
must use the same provider id, which broke session/llm-native tests
that build models with provider 'amazon-bedrock' against the
bedrock-converse adapter.

Adapters should stay protocol-only by default and only set provider
when the deployment is genuinely scoped (e.g. an Azure-only adapter
that does not work for native OpenAI). Restoring the original
protocol-only registration.
2026-05-01 08:12:36 -04:00
Kit Langton 31740c1d36 simplify(llm): inline single-use DEFAULT_BASE_URL / defaultBaseURL constants
Per style guide, single-use values should be inlined. Each adapter had
a module-private constant used exactly once in its Adapter.fromProtocol
call. Inlining removes 5 named constants (4 DEFAULT_BASE_URL + 1
defaultBaseURL + ANTHROPIC_VERSION) without loss of clarity — the
string literal appears at the point of use.
2026-05-01 08:12:36 -04:00
Kit Langton 9928917899 simplify(llm): remove dead ProviderShared.sse and withQuery helpers
After migration to Adapter.fromProtocol, the sse() convenience wrapper
and withQuery() URL builder are no longer called anywhere — Framing.sse
and Endpoint.baseURL handle their responsibilities directly. Also
inlines two exported-but-unused test constants (helloPrompt,
weatherPrompt) per style guide.
2026-05-01 08:12:36 -04:00
Kit Langton 98cb886faf docs(llm): document Protocol/Endpoint/Auth/Framing architecture
Updates the AGENTS.md adapter section to describe the four orthogonal
axes that make up an adapter today (Protocol + Endpoint + Auth + Framing)
and the canonical Adapter.fromProtocol composition. Adds a folder layout
overview so the dependency direction (provider/* imports protocol/auth/
endpoint/framing, never the other way) is visible.
2026-05-01 08:12:36 -04:00
Kit Langton bdd01cad33 refactor(llm): migrate remaining adapters to fromProtocol
Extracts a Protocol implementation per provider and wires the adapter
through Adapter.fromProtocol with explicit Endpoint, Auth, and Framing:

- OpenAI Responses — Endpoint.baseURL with /responses path.
- Anthropic Messages — adds anthropic-version header via the headers slot.
- Gemini — endpoint embeds the model id and pins ?alt=sse at the URL level.
- Bedrock Converse — keeps SigV4-or-Bearer auth as a typed Auth function;
  AWS event-stream framing is a typed Framing value alongside the protocol;
  Endpoint.baseURL gains a function-typed default so the URL host can carry
  the per-request region.

Recorded replay byte-identical across all six adapters; full provider
suite 83 pass, full llm suite 122 pass, opencode typecheck clean.
2026-05-01 08:12:36 -04:00
Kit Langton 6ed160ae02 refactor(llm): migrate OpenAI Chat adapters to fromProtocol
Extracts OpenAIChat.protocol so that:

- openai-chat is now a four-line Adapter.fromProtocol composition over
  the protocol, the OpenAI base URL, default passthrough auth, and SSE
  framing.
- openai-compatible-chat reuses OpenAIChat.protocol verbatim. The whole
  adapter is one Adapter.fromProtocol call that pins protocolId to
  openai-compatible-chat and requires a caller-supplied baseURL.

Bug fixes in OpenAIChat.protocol now propagate to DeepSeek, TogetherAI,
Cerebras, Baseten, Fireworks, DeepInfra, and any future OpenAI-compatible
deployment without touching their files. Recorded replay byte-identical.
2026-05-01 08:12:36 -04:00
Kit Langton 7505da95d3 feat(llm): add Protocol, Endpoint, Auth, Framing primitives
Introduces the four orthogonal axes that an LLM adapter is composed of:

- Protocol — semantic API contract (lowering, validation, encoding,
  parsing). Examples: OpenAI Chat, Anthropic Messages, Bedrock Converse.
- Endpoint — URL construction (baseURL + path + query params).
- Auth — per-request transport authentication. Defaults to passthrough
  for adapters whose auth header is baked into model.headers.
- Framing — byte stream to frames (SSE today; AWS event stream next).

Adds Adapter.fromProtocol(...) which composes these into the existing
AdapterDefinition shape so LLMClient.make(...) and the runtime registry
do not change. Existing adapters keep working through Adapter.define
until they migrate one at a time.
2026-05-01 08:12:36 -04:00
Kit Langton 6099b3dfe9 refactor(llm): rename Protocol type to ProtocolID
Frees up the Protocol name for the upcoming Protocol implementation type
that owns request lowering, target validation, and stream parsing as a
single composable unit. Field names on ModelRef and Adapter stay as
'protocol' since they carry the string discriminator value.
2026-05-01 08:12:36 -04:00
Kit Langton 20bab34b01 test(llm): share recorded provider scenarios 2026-05-01 08:12:36 -04:00
Kit Langton cd7487a73b test(llm): add focused recorded test filters 2026-05-01 08:12:36 -04:00
Kit Langton a921eb88e6 test(opencode): cover Azure native request mapping 2026-05-01 08:12:36 -04:00
Kit Langton f2f7a338de feat(llm): resolve Azure provider natively 2026-05-01 08:12:36 -04:00
Kit Langton 7141036ec4 refactor(llm): simplify provider resolver defaults 2026-05-01 08:12:36 -04:00
Kit Langton b0be03facd refactor(llm): clarify provider resolution 2026-05-01 08:12:35 -04:00
Kit Langton 1cd53b27ec chore(llm): clean up PR docs 2026-05-01 08:12:35 -04:00
Kit Langton 59f39a922f chore(opencode): drop local LLM adapter spec from branch 2026-05-01 08:12:35 -04:00
Kit Langton 7fba0efbd9 fix(opencode): update native LLM imports after rebase 2026-05-01 08:12:35 -04:00
Kit Langton 0e558e13c7 feat(opencode): populate nativeTools from prompt.ts so production sessions can route through the native path (audit gap #4 phase 2 step 3)
Wires the prompt-side tool resolver to also surface opencode-native
`Tool.Def[]` alongside the AI SDK record it already builds. With
`OPENCODE_EXPERIMENTAL_LLM_NATIVE=1` set, real production sessions
that satisfy the gate now stream through `LLMNativeTools.runWithTools`
instead of `streamText` — the LLM-native path goes from
"plumbing-only" to "actually used."

Changes:

- `prompt.ts:resolveTools` collects `Tool.Def[]` from the registry
  loop and tracks a feasibility flag. MCP tools (which only have AI
  SDK shape) flip the flag off; the synthesized `StructuredOutput`
  tool that the json_schema branch injects also flips it. The return
  shape becomes `{ tools, nativeTools }` where `nativeTools` is
  `undefined` whenever any non-registry tool source contributes —
  callers fall through to the AI SDK path automatically. The
  registry path stays in sync because every `tools[item.id] =
  tool({...})` is paired with a `nativeTools.push(item)` at the same
  loop iteration.

- The single caller (`prompt.ts:1396`) destructures the new shape
  and passes `nativeTools` through to `handle.process(...)`. The
  json_schema branch sets `nativeTools = undefined` after injecting
  `StructuredOutput` so the gate falls through for structured-output
  sessions.

- `runNative` (in `session/llm.ts`) gains two safety nets that work
  regardless of caller behavior:

    1. Coverage check: if AI SDK tools are non-empty, every key must
       have a matching `Tool.Def` in `nativeTools`. A partial set
       falls through. Defends against future callers that might
       emit a partial native list.

    2. Filter parity: `runNative` now calls the existing
       `resolveTools(input)` (the in-file permission/user-disabled
       filter) and intersects its keys with `nativeTools`, then
       feeds the filtered AI SDK record to the dispatcher and the
       filtered native list to `LLMNative.request`. Without this,
       sessions could see permission-disabled tools advertised on
       one path but not the other.

- The dispatch path uses the filtered AI SDK tools record as the
  execute table: `LLMNativeTools.runWithTools({ tools:
  filteredAITools, ... })`. Tool definitions sent to the model are
  the filtered native list. Every tool the model sees can dispatch.

What this enables: a session opted into the experimental flag, with
a clean toolset (registry-only, no MCP, no structured output),
running an Anthropic model, now exercises the streaming-dispatch
loop end-to-end. Tool calls fire as soon as the model finishes
streaming each tool's input; results land in the stream the moment
each handler resolves. Multi-round behavior matches phase 2 step 2b.

What this still does NOT do (deferred to step 4):

- Parity test harness comparing native vs AI SDK event sequences for
  the same scripted session. Until that lands, broader confidence
  comes from running real sessions with the flag set.
- MCP support on the native path. Sessions with MCP servers
  configured stay on AI SDK indefinitely.
- Native support for the synthesized `StructuredOutput` tool.

Verification: opencode typecheck clean for `src/session/*` (the
TUI-side errors visible in the working tree are Kit's parallel
work, untouched here); bridge area tests 36/0/0 across
`llm-native.test.ts` + `llm-native-stream.test.ts` +
`llm-bridge.test.ts`; `prompt.test.ts` still 47/0/0 (no regression
from the resolveTools shape change).
2026-05-01 08:12:35 -04:00
Kit Langton afa57acfda refactor(llm): extract HTTP recorder package 2026-05-01 08:12:35 -04:00
Kit Langton 189161ed62 feat(opencode): streaming tool dispatch and multi-round loop on the native path (audit gap #4 phase 2 step 2b)
Lands the streaming-dispatch tool loop for the LLM-native path. When
the gate-passing session has `nativeTools` populated, the native
runner forks an AI SDK `tool.execute(...)` the moment a `tool-call`
event arrives mid-stream and injects a synthetic `tool-result` event
back into the same stream when the handler resolves. Long-running
tools no longer block subsequent tool-call streaming; the user sees
each result land as soon as that specific handler completes.

The driver loops across rounds: when a round ends with `reason:
"tool-calls"` AND the dispatchers produced at least one result, the
runner builds a continuation `LLMRequest` (assistant message echoing
text/reasoning/tool-call content + tool messages with results) and
recurses. Stops on a non-`tool-calls` finish, when `maxSteps`
(default 10, mirrors `ToolRuntime.run`) is reached, or when the
underlying scope is interrupted.

New file `session/llm-native-tools.ts`:

- `runWithTools({ client, request, tools, abort, maxSteps? })` is the
  public entry point. Returns a `Stream<LLMEvent, LLMError,
  RequestExecutor.Service>` of merged model events + synthetic tool
  results, ready to flow through `LLMNativeEvents.mapper` for
  consumption by the existing session processor.
- `runOneRound` is the internal building block. It opens an unbounded
  `Queue<LLMEvent, LLMError | Cause.Done>`, forks a producer that
  streams the model and pushes each event to the queue, and forks a
  dispatcher (via a scope-bound `FiberSet`) for every
  non-provider-executed `tool-call`. Each dispatcher's result is
  pushed back into the same queue. After the model stream completes,
  the producer awaits `FiberSet.awaitEmpty` and ends the queue;
  consumers see end-of-stream. A `Deferred<RoundState>` resolves
  alongside so the multi-round driver can decide whether to recurse.
- `dispatchTool` wraps the AI SDK `tool.execute(input, { toolCallId,
  messages, abortSignal })` call. Unknown-tool and execute-throws
  paths produce `tool-error` events instead of failing the stream
  (mirrors `ToolRuntime.run`'s defect-vs-recoverable boundary), so
  the model can self-correct on the next round.

Wired into `runNative` (`session/llm.ts`): when `input.nativeTools`
is non-empty, the upstream becomes `LLMNativeTools.runWithTools(...)`
instead of `nativeClient.stream(...)`; the AI SDK `tools` record
flows in as the dispatch table. Zero-tool sessions still take the
direct-stream path (one round, no dispatch overhead).

Mapper update (`session/llm-native-events.ts`): `tool-result` events
whose `result.value` matches the opencode `Tool.ExecuteResult` shape
(`{ output: string, title?: string, metadata?: object }`) now flow
through to the AI-SDK-shaped session event with their `title` and
`metadata` preserved. Provider-executed and synthetic results that
don't match still fall back to `stringifyResult`. Without this, the
session processor would see every native tool result as
`{ title: "", metadata: {}, output: <JSON of the whole record> }`.

Smoke test (`test/session/llm-native-stream.test.ts`): scripts a
two-round Anthropic SSE backend — round 1 issues a `lookup` tool
call, round 2 replies with text after the tool result feeds back.
Asserts the full event sequence threads through `runWithTools`,
the dispatcher, and the mapper:

- `tool-call` event has the streamed JSON input parsed.
- `tool-result` event carries the `ExecuteResult` shape with
  `title` + `output` populated (proving the mapper update works).
- Round 2 text-delta arrives after the synthetic tool-result.
- Final `finish` event has `finishReason: "stop"` (loop terminated).

What this still does NOT do (deferred to step 3):

- No production caller populates `nativeTools` yet; that's the
  `prompt.ts:resolveTools` change. Until that lands, the gate keeps
  every real session on the AI SDK path.
- No parity harness comparing native + AI SDK event sequences for
  the same scripted session. That's step 4.

Verification: opencode typecheck clean; 36/0/0 across the three
bridge-area tests; 125/0/0 across the LLM package.
2026-05-01 08:12:35 -04:00
Kit Langton fa8f7a1dca feat(opencode): plumb nativeTools through StreamInput (audit gap #4 phase 2 step 2a)
Adds opt-in `nativeTools?: ReadonlyArray<Tool.Def>` to `LLM.StreamInput`
so callers that route through the native path can attach typed
opencode tool definitions alongside the AI SDK `tools` record. The
gate in `runNative` widens accordingly: a session can use the native
path when it has zero tools (existing behavior) OR when it explicitly
provides `nativeTools` matching its AI SDK `tools` (new opt-in). When
`nativeTools` reaches `LLMNative.request`, the existing
`toolDefinition` converter folds each `Tool.Def` into the request's
`tools` array and the LLM core lowers it onto the wire.

This commit deliberately does NOT include the dispatch loop. A
session that opts in by setting `nativeTools` and that triggers a
`tool-call` from the model will see the call event but no
`tool-result` because the native path has no execute handler yet.
That's why no production caller populates `nativeTools`: phase 2
step 2b will land the dispatch loop and only then will real
production sessions route through here.

What this lays in place:

- `StreamInput.nativeTools` typed against `Tool.Def[]` from `@/tool`.
  Aliased to `OpenCodeTool` at the import to dodge a clash with the
  AI SDK `Tool` type that the same file already imports.
- The `runNative` gate flips from "no tools allowed" to "either no
  tools, or `nativeTools` is supplied". An AI SDK tool count > 0
  with `nativeTools` undefined still falls through, so existing
  production sessions are unaffected.
- `LLMNative.request` already accepted `tools: ReadonlyArray<Tool.Def>`
  and converts via `toolDefinition`. We just forward the input
  through; no LLM-bridge change.

Smoke coverage: a new test in `llm-native-stream.test.ts` builds a
typed `Tool.Def` (Effect Schema parameters), routes it through
`LLMNative.request` + `LLMClient.prepare`, and asserts the prepared
Anthropic target carries the tool as an `input_schema` block with
the expected JSON Schema shape. This validates the conversion path
that phase 2 step 2b will exercise from inside `runNative`.

Verification: opencode typecheck clean; 35/0/0 across the three
bridge-area tests (`llm-native.test.ts`, `llm-native-stream.test.ts`,
`llm-bridge.test.ts`).
2026-05-01 08:12:35 -04:00
Kit Langton afba37d330 test(opencode): smoke test for LLM-native stream wire-up (audit gap #4 phase 2)
Adds `test/session/llm-native-stream.test.ts` — one focused test that
proves the end-to-end wire-up `runNative` relies on actually produces
session events from a scripted Anthropic SSE response.

The test stays self-contained:

- Builds a fake Anthropic `Provider.Info` + `Provider.Model` via
  `ProviderTest`.
- Builds an `LLMRequest` via `LLMNative.request(...)` from a
  `MessageV2.WithParts` user message — the same call shape `runNative`
  uses inside `session/llm.ts`.
- Creates an `LLMClient` with the same adapters list + `ProviderPatch.defaults`
  list as `runNative`. The adapters are imported directly from
  `@opencode-ai/llm`; if `runNative`'s `NATIVE_ADAPTERS` array changes,
  this test's `adapters` constant has to follow (commented).
- Provides a single fixed-response HTTP layer that returns a scripted
  Anthropic SSE body. The layer helper is inlined (12 lines) rather
  than imported from `packages/llm/test/lib/http.ts` so the test
  doesn't reach across package boundaries.
- Pipes the LLM stream through `LLMNativeEvents.mapper()` exactly as
  `runNative` does (`Stream.flatMap` + lazy `Stream.concat` for
  flush), runs it to completion, and asserts the key session events:
  `text-start` precedes `text-delta`, `finish-step` carries
  `finishReason: "stop"`, and `finish` carries the merged usage totals.

This does NOT test the dispatch gate inside `session/llm.ts`
(`!Flag.OPENCODE_EXPERIMENTAL_LLM_NATIVE`, missing `nativeMessages`,
tools present, non-Anthropic protocol). Those are simple boolean
conditions and don't need separate coverage. It also does not exercise
the production `Service` layer — that's deferred to Phase 2 step 2
(tool support) and Phase 2 step 3 (production caller wiring).

What the test buys: confidence that the conversion pipeline works and
catches regressions in `LLMNative.request`, the LLM adapter set, or
`LLMNativeEvents.mapper` before they would surface in a real session.

Verification: 34/0/0 across the three bridge-area tests
(`llm-native.test.ts` + `llm-native-stream.test.ts` +
`llm-bridge.test.ts`); opencode typecheck clean.
2026-05-01 08:12:35 -04:00
Kit Langton fc3a1bfd34 feat(opencode): wire LLM-native stream path behind opt-in flag (audit gap #4 phase 1)
Adds the parallel `runNative()` path inside `session/llm.ts` so a narrow
slice of sessions can flow through `@opencode-ai/llm` instead of the AI
SDK `streamText`. Behavior is gated and shipped off by default; only
callers that opt in see any difference.

The full migration plan (audit gap #4) is parallel-path-with-flag,
prove parity test-by-test, flip default last. This commit is phase 1:
get the wire-up in place behind a flag with one protocol so we can see
whether the design holds before committing to the full migration.

Wire-up summary:

- New flag `OPENCODE_EXPERIMENTAL_LLM_NATIVE` (also enabled by the
  umbrella `OPENCODE_EXPERIMENTAL`). Off by default.
- The session-LLM `live` layer now consumes `RequestExecutor.Service`,
  and the `defaultLayer` provides `RequestExecutor.defaultLayer` so a
  Node fetch HTTP client backs every native stream.
- `runNative(input)` returns `Stream<Event> | undefined`. `undefined`
  means "fall through to AI SDK." It returns a real stream only when
  every gate passes: the flag is set, the caller populated
  `input.nativeMessages` (the bridge needs typed `MessageV2.WithParts`,
  not the AI SDK `messages` array), the session has zero tools (Phase
  2 will lift this), and the bridge routes the model to a protocol in
  `NATIVE_PROTOCOLS`.
- `NATIVE_PROTOCOLS` is a single-entry set today: `anthropic-messages`.
  Other adapters are imported and registered with the client so the
  Phase 2 expansion is a one-line edit, not an architecture change.
- Stream wiring: client.stream(req) -> Stream.flatMap(event ->
  fromIterable(map.map(event))) -> Stream.concat(suspended
  fromIterable(map.flush())) -> Stream.provideService(
  RequestExecutor.Service, executor). The flush stream is built lazily
  with `Stream.unwrap(Effect.sync(...))` so it observes the mapper
  final state after every upstream event has been mapped.
- The mapper (`LLMNativeEvents.mapper`) emits AI-SDK-shaped session
  events from `LLMEvent` so downstream consumers see one shape.

What this does NOT do (deferred to later phases):

- No tool support on the native path (skipped, falls through).
- No parity harness yet; Phase 2 builds it.
- No production traffic; flag is off by default and no production
  caller populates `nativeMessages`.
- No reasoning/cache/multi-modal coverage. Anthropic supports reasoning
  and cache via existing patches, so those start working as soon as a
  caller routes a real session through.

Verification: opencode typecheck clean, bridge tests still green
(33/0/0 across llm-native.test.ts + llm-bridge.test.ts); LLM package
tests green (123/0/0).
2026-05-01 08:12:35 -04:00
Kit Langton 0ba8ca63b6 refactor(llm): Bedrock JSON-codec compliance, signing-headers cleanup, and small dedup
Five review findings; all small, all independent.

H2: Bedrock used raw `JSON.parse` and `JSON.stringify` despite the
package rule against ad-hoc JSON encoders. The in-loop parse on each
event-stream frame goes through `ProviderShared.parseJson` (yielded
inside `Effect.gen`); the `decodeChunk` error fallback uses
`ProviderShared.encodeJson` instead of `JSON.stringify` for the raw
field on `ProviderChunkError`. No behavior change — just channels
JSON through the shared Schema-driven codec.

H3: `BedrockConverse.toHttp` built a `baseHeaders` record with
`content-type: application/json` and passed it through both auth
paths. The bearer path called `jsonPost` with the raw model headers
(no manual content-type), the SigV4 path used `baseHeaders` plus the
signed result. Two paths produced subtly different header sets and
both relied on `jsonPost` overwriting/adding the same content-type
key. Simplify: drop the unused bearer-side construction; rename the
SigV4 input to `headersForSigning` and document why content-type
must be present at signing time (signature covers it).

M4: Lift `isRecord` from `gemini.ts` into `ProviderShared.isRecord`
so adapters share one definition. The duplicates in `llm.ts` (LLM IR
layer) and `llm-native.ts` (OpenCode bridge) stay where they are —
those are at different layers and importing from `provider/` would
invert the dependency direction. Net effect: the provider layer
goes from 2 copies to 1.

L8: `TransportError` lost everything but the message string.
Surface the originating reason tag (`Timeout` / `TransportError` /
`ResponseError` / `RequestError`) and the request URL when
available, both as optional Schema fields. Consumers that don't
care keep getting the same `message` rendering; consumers that do
can finally render "timed out connecting to https://..." instead
of "HTTP transport failed".

M9 + L3: Two dead branches. Anthropic's `processChunk` had
`?? ""` fallbacks for `partial_json` after an early-return guard
already proved it non-empty. OpenAI Chat's `mapFinishReason` had
`if (reason === undefined || reason === null) return "unknown"`
followed by `return "unknown"` — both branches went to the same
place. Drop the unreachable code.

120 LLM-package tests + 33 OpenCode bridge tests still green.
2026-05-01 08:12:35 -04:00
Kit Langton 38af0dc6f8 refactor(llm): centralize codec scaffolding, ToolAccumulator, and totalTokens policy
Three review findings collapsed into one ProviderShared pass.

M1: Five adapters duplicated the same six-line block:

    const ChunkJson = Schema.fromJsonString(Chunk)
    const TargetJson = Schema.fromJsonString(Target)
    const decodeChunkSync = Schema.decodeUnknownSync(ChunkJson)
    const encodeTarget = Schema.encodeSync(TargetJson)
    const decodeTarget = Schema.decodeUnknownEffect(Draft.pipe(Schema.decodeTo(Target)))
    const decodeChunk = (data) => Effect.try({...chunkError(...)})

Lift it into `ProviderShared.codecs({ adapter, draft, target, chunk,
chunkErrorMessage })` returning `{ encodeTarget, decodeTarget,
decodeChunk }`. The result drops directly into `Adapter.define`'s
`validate` field (uses `validateWith` internally to map parse errors
to InvalidRequestError). Adopted in OpenAI Chat, OpenAI Responses,
Anthropic Messages, and Gemini. Bedrock has a custom event-stream
`decodeChunk` that takes `unknown` (not `string`) so it keeps its
inline codecs.

M2: Four adapters defined an identical `ToolAccumulator` interface
(`{ readonly id: string; readonly name: string; readonly input:
string }`). Lift to `ProviderShared.ToolAccumulator`. Anthropic
extends it locally with `providerExecuted` for hosted tools.

M3: The five `mapUsage` implementations had subtly different
`totalTokens` policies — OpenAI Chat passed through whatever the
provider sent, OpenAI Responses unconditionally summed inputs and
output (publishing `totalTokens: 0` when both were `undefined`),
Anthropic and Gemini guarded with conditionals, Bedrock used a
`(...) || undefined` falsy fallback. Add `ProviderShared.totalTokens`
with one rule: prefer provider-supplied total, else sum inputs and
outputs only when at least one is defined, else `undefined`. Fixes
the OpenAI Responses `totalTokens: 0` bug.

M6: Anthropic's `mergeUsage` recomputed `totalTokens` from the merged
input/output via two nested ?? chains and a conditional sum.
Simplified to use the same totalTokens helper, with `inputTokens` and
`outputTokens` extracted as locals so the merge is one ?? per field
and the comment explains why merging exists (Anthropic emits usage
on `message_start` and `message_delta`).

No behavior changes other than the OpenAI Responses fix; existing
tests pass unchanged. 120 LLM-package tests + 33 OpenCode bridge
tests green.
2026-05-01 08:12:35 -04:00
Kit Langton 8bbbceef92 fix(llm): unify apiKey precedence and consolidate Gemini schema conversion
Two issues from the review of the LLM package's six adapters.

H1: Inconsistent apiKey precedence. Five of six adapters spread the
caller's headers first then set the auth header (apiKey wins), but
`OpenAICompatibleChat.model` did the opposite (caller headers won).
That meant a user passing both `apiKey` and `headers.authorization`
would get auth from a different source depending on which adapter
they routed through. Flip the OpenAI-compatible adapter to match the
rest, and add a comment documenting the rule: apiKey wins, callers
who want their own auth header should omit `apiKey` entirely.

H4: Gemini tool-schema sanitization was split across two functions
that both ran on every Gemini request — `convertJsonSchema` in the
adapter (lossy projection: drop empty objects, derive nullable from
type-array, allowlist of preserved keys, recursive properties/items)
and `sanitizeGeminiSchemaNode` registered as a default `tool-schema`
patch (fix-up: integer enums to strings, dangling required filtering,
untyped array typing, scalar property stripping). Both passes only
ran on Gemini models; debugging a tool schema rejection meant
checking both files.

Fold the patch's rules into the adapter as `sanitizeToolSchemaNode`,
running before the existing projection step (renamed
`projectToolSchemaNode`). Compose them in `convertToolSchema` and use
that in `lowerTool`. Delete the patch from `provider/patch.ts` and
`ProviderPatch.defaults`. The behavior is unchanged — same input,
same output — but the rules now live in one file with a header
comment explaining the two concerns.

The matching test in `gemini.test.ts` no longer needs to opt into a
patch list; it now asserts the adapter alone produces the sanitized
shape.
2026-05-01 08:12:35 -04:00
Kit Langton d00db17902 feat(opencode): add native LLM event bridge 2026-05-01 08:12:35 -04:00
Kit Langton f59996362e feat(opencode): round-trip encrypted reasoning content through the bridge
Closes audit gap #3. The bridge now extracts the encrypted reasoning
blob from `MessageV2.ReasoningPart.metadata` and surfaces it on
`LLM.ReasoningPart.encrypted`, where the Anthropic and Bedrock
adapters lower it to the wire — Anthropic emits `thinking.signature`,
Bedrock emits `reasoningContent.reasoningText.signature`. Without
this, multi-turn sessions with reasoning models would lose the
encrypted state on every step and break the chain.

The encrypted blob originates in three different places depending on
how the session was started:

1. AI-SDK Anthropic sessions store it as
   `metadata.anthropic.signature` (per AI SDK provider-keyed
   convention).
2. AI-SDK OpenAI sessions store it as
   `metadata.openai.reasoningEncryptedContent`.
3. Future LLM-native sessions will store it as a top-level
   `metadata.encrypted` string (cleanest shape — provider-agnostic,
   matches the LLM IR field name).

The new `encryptedReasoning` helper probes all three locations in
order, so existing OpenCode sessions can be served by the LLM-native
path without re-recording reasoning content. The full `metadata`
record continues to flow through to `LLM.ReasoningPart.metadata`
unchanged, preserving any provider-specific fields adapters might
read in the future.

OpenAI Responses encrypted reasoning round-trip is intentionally out
of scope: the LLM-package adapter doesn't yet model reasoning items
in the request body. That's a separate adapter feature requiring new
input-item schema variants and is deferred until needed.

Tests (5 new in llm-native.test.ts):
- AI-SDK Anthropic signature extracted into LLM.ReasoningPart.encrypted.
- End-to-end Anthropic lowering: bridge \u2192 client.prepare \u2192 target with
  `thinking.signature` populated correctly.
- AI-SDK OpenAI reasoningEncryptedContent extracted (forward
  compatibility — useful when the OpenAI Responses adapter gains
  reasoning-item lowering).
- Top-level metadata.encrypted extracted (LLM-native session shape).
- No known key in metadata leaves `encrypted` undefined.

Verified: 33/0/0 across native + bridge tests (was 28; +5 from the
new reasoning extraction tests).
2026-05-01 08:12:35 -04:00
Kit Langton b653261772 feat(opencode): bridge user FilePart to LLM MediaPart for vision input
Closes audit gap #2 (FilePart \u2192 MediaPart not implemented).

The bridge now lowers `MessageV2.FilePart` on user messages into
`LLM.MediaPart`, unblocking image and document inputs. The first
pass supports `data:` URLs only — the inline base64 form most
commonly produced by the OpenCode UI for pasted screenshots and
attached files. `http(s):` and `file:` URLs are explicitly
rejected with a clear error so a future fetch / filesystem-read
path can plug in cleanly without regressing safety.

Implementation:
- New `lowerFilePart` helper extracts the base64 payload from a
  data URL via a single regex; failure yields a typed
  `UnsupportedContentError` carrying both the partType and a
  `reason` that includes the offending URL for debuggability.
- New `lowerUserPart` dispatches user-side parts: text \u2192
  `LLM.text`, file \u2192 `MediaPart`. Returns identity-empty
  for any unsupported part type the static gate would have caught.
- `userMessage` is now `Effect.fnUntraced` so file conversion can
  yield typed errors. `lowerMessage` (the per-message dispatcher,
  renamed from `messages` to free the local name) cascades the
  Effect through the request flow via `Effect.forEach`.
- `supportsPart` static gate now allows `file` parts on user
  messages. Assistant messages still reject file parts (the LLM
  IR's MediaPart isn't valid in assistant content for any
  adapter we ship today).
- `UnsupportedContentError` gains an optional `reason` field that
  appends to the canonical message as `<base>: <reason>`. Existing
  static-gate failures keep the same shape (no reason).

Tests (3 new, 1 rewritten):
- Image data URL with filename round-trips to MediaPart with
  base64-stripped data.
- PDF data URL preserves filename and base64 payload.
- `https:` URL rejected with an error mentioning both the file
  partType, the message ID, and the offending URL.
- The pre-existing "fails instead of dropping unsupported native
  parts" test now uses a reasoning part on a user message
  (reasoning is valid for assistants only) since file parts with
  data URLs are no longer rejected by the static gate.

Out of scope, intentional follow-ups:
- HTTP/HTTPS URL fetching (would need HttpClient.HttpClient and a
  decision on caching, retries, size limits).
- File path / file:// URL reading (would need FileSystem.FileSystem
  and a permission check against the session's working directory).
- File parts on assistant messages (LLM IR doesn't model
  assistant-side media; defer until we hit a provider that needs it).
- text/plain and application/x-directory file parts that the
  AI-SDK path converts to text inline at message-v2.ts:791 — for
  the bridge, those should be converted upstream before reaching
  LLMNative.request rather than handled here.

Verified: bun typecheck clean, 28/0/0 across native + bridge
tests (was 21; +7 from the FilePart additions plus the rewritten
unsupported-parts test).
2026-05-01 08:12:34 -04:00
Kit Langton 5f08d6cbd6 feat(llm): cachePromptHints patch with first-2 system / last-2 messages policy
Lift the prompt-cache policy out of OpenCode's bridge and into the
LLM package as a typed, gated patch. The policy mirrors the AI-SDK
applyCaching path (packages/opencode/src/provider/transform.ts:229):
mark the first 2 system parts and the last 2 messages with an
ephemeral cache hint, gated on `model.capabilities.cache.prompt`.

Adapters lower the hint structurally — Anthropic emits
`cache_control: { type: "ephemeral" }` on the marked block,
Bedrock emits a positional `cachePoint: { type: "default" }`
after the marked block (added in 9d7d518ac). The capability gate
keeps non-cache adapters (OpenAI Responses, Gemini, OpenAI-compat
Chat) hint-free.

Why a Patch and not bridge code:
- packages/llm/AGENTS.md TODO explicitly calls for cache hint patches
- Other consumers of @opencode-ai/llm get caching for free
- The bridge stays focused on shape conversion (MessageV2 \u2192 LLMRequest)
- Patches compose via ProviderPatch.defaults (now includes this one)
- The capability gate is a typed predicate, not provider-name matching

Implementation:
- New `cachePromptHints` patch in provider/patch.ts. The
  `withCacheOnLastText` helper uses Array.findLastIndex (codebase
  idiom) and short-circuits when no text part exists so messages
  with only tool-result content are returned identity-equal.
- `EPHEMERAL_CACHE` is a single shared CacheHint instance — no
  per-request allocation, preserves `instanceof` for any consumer
  that checks class identity.
- Added to `ProviderPatch.defaults` so existing callers that pass
  `defaults` get cache support automatically.

Tests (5 new in patch.test.ts):
- Marks first 2 system parts on cache-capable models.
- Marks last text part of last 2 messages.
- Targets the last text part when a message has trailing
  non-text content (assistant text + tool-call).
- Returns content unchanged (identity-equal) when no text part
  exists, so pure tool-result messages don't allocate.
- No-op when the model does not advertise prompt caching.

Bridge cleanup:
- Removed `applyCachePolicy`, `withCacheOnLastText`,
  `updateMessageContent`, `EPHEMERAL_CACHE` from llm-native.ts
  (-30 lines of bridge-side cache code).
- Dropped now-unused `CacheHint`, `LLMRequest`, `Message` imports.
- The bridge's only responsibility is now MessageV2 lowering;
  callers wire `patches: ProviderPatch.defaults` at client
  construction.

OpenCode tests rewritten:
- Old: assert on `request.system[N].cache` (bridge internals).
- New: assert on `prepared.target` after running through
  `LLMClient.make({ adapters, patches: ProviderPatch.defaults })
  .prepare(request)` — verifies the full lowering end-to-end.
- Anthropic: target.system[0..1] carry `cache_control: ephemeral`,
  target.messages[1..2] carry it on the final text block.
- Bedrock: target has `cachePoint` markers after each cached block.
- Non-cache (OpenAI Responses): JSON.stringify(target) contains
  none of `cache_control` / `cachePoint` / `ephemeral`.

Verified: bun typecheck clean across both packages, 120/0/0 in LLM
package (was 113; +7 from new patch tests counting parameter
variations), 21/0/0 in OpenCode native+bridge tests.
2026-05-01 08:12:34 -04:00
Kit Langton 3cd13c87c4 refactor(llm): standardize native request APIs 2026-05-01 08:12:34 -04:00
Kit Langton 653a830cf6 refactor(llm): clarify tool definition API 2026-05-01 08:12:34 -04:00
Kit Langton 33ef3b01f8 test(opencode): cover native Gemini parity 2026-05-01 08:12:34 -04:00
Kit Langton a26f2c905f test(opencode): cover native OpenAI-compatible parity 2026-05-01 08:12:34 -04:00
Kit Langton 03a97a64a3 chore(llm): fix low-hanging lint warnings 2026-05-01 08:12:34 -04:00
Kit Langton 096c305a55 feat(llm): Bedrock Converse cache hints, image, and document blocks
Close the parity gaps deferred from the original Bedrock pass.

Schema additions on the Converse target:
- BedrockImageBlock for { image: { format, source: { bytes } } }.
  Supported formats per Converse docs: png, jpeg, gif, webp.
- BedrockDocumentBlock for { document: { format, name, source: { bytes } } }.
  Supported formats: pdf, csv, doc, docx, xls, xlsx, html, txt, md.
- BedrockCachePointBlock for the positional { cachePoint: { type } }
  marker. Currently emits the only Bedrock cache type, 'default'. A
  TODO marks where to map ttlSeconds → ttl ('5m' | '1h') once we have
  a recorded cassette to validate the wire shape.

Lowering:
- TextPart and SystemPart cache hints emit a positional cachePoint
  marker right after their text block. Both 'ephemeral' and
  'persistent' CacheHint types map onto Bedrock's 'default' since
  Bedrock does not distinguish — this matches the convention the
  Anthropic adapter uses (cache?.type === 'ephemeral' check).
- MediaPart routes by mediaType: 'image/*' → image block, everything
  else → document block. MIME type → format mapping is via
  IMAGE_FORMATS / DOCUMENT_FORMATS records typed with 'as const
  satisfies' so the keys stay narrow at compile time.
- A small textWithCache helper collapses the 'push text, push
  cachePoint if cache is set' pattern that would otherwise repeat at
  three callsites (system, user-text, assistant-text).
- Bytes are encoded via ProviderShared.mediaBytes — the shared
  helper Kit landed in c3346f7dc.

Bug fix: lowerSystem was dead code in the previous draft. The
prepare() function still inlined the pre-cache .map(...) that
discarded system cache hints. prepare() now calls lowerSystem so
the cachePoint markers actually flow through.

Tests (7 new fixtures, all green):
- Cache hint on system / user-text / assistant-text emits cachePoint
  after text in each context.
- No cache hint → no cachePoint emitted (regression guard).
- Image lowering covers png / jpeg / jpg-alias / webp.
- Uint8Array image bytes are base64-encoded ([1,2,3,4,5] → AQIDBAU=).
- Document lowering with filename round-trip and missing-filename
  fallback to 'document.<format>'.
- Unsupported image MIME (image/svg+xml) is rejected with a clear
  error message.
- Unsupported document MIME (application/x-tar) is rejected with a
  clear error message.

Recorded cassettes for cache hints, images, and documents are still
TODO — the wire shapes are exercised deterministically here and will
be validated against a live model in a follow-up cassette pass.

Verified: bun typecheck clean, 113 pass / 0 fail / 0 skip (was 106;
+7 from the new fixture tests).
2026-05-01 08:12:34 -04:00
Kit Langton ecd73f26fc refactor(llm): simplify adapter shared logic 2026-05-01 08:12:34 -04:00
Kit Langton 1a839c6233 refactor(opencode): tighten native LLM bridge boundaries 2026-05-01 08:12:34 -04:00
Kit Langton c69f2bb15e refactor(llm): centralize InvalidRequestError, validate, and JSON POST
Phase A continuation of the ProviderShared dedupe pass. Three more
patterns lifted into ProviderShared so they're written once:

ProviderShared.invalidRequest(message) — replaces six identical
`const invalid = (message) => new InvalidRequestError({ message })`
one-liners across openai-chat, openai-responses, anthropic-messages,
gemini, openai-compatible-chat, and bedrock-converse. Each adapter
keeps a short `const invalid = ProviderShared.invalidRequest` alias
so the 27 callsite `yield* invalid("...")` patterns are unchanged.
Bedrock's SigV4 catch path and the openai-compatible-chat baseURL
guard both go through the helper now too.

ProviderShared.validateWith(decode) — replaces the identical
`(draft) => decode(draft).pipe(Effect.mapError((e) =>
invalid(e.message)))` lambda body in five adapters. Same line count
but shorter, names the pattern, and keeps the `decode → mapError →
InvalidRequestError` translation in one canonical spot.

ProviderShared.jsonPost({ url, body, headers }) — replaces the
five-adapter pattern of `HttpClientRequest.post(url).pipe(setHeaders,
bodyText)` for JSON-body POSTs. Sets `content-type: application/json`
last so caller headers can override everything except the
content-type. Bedrock uses it for both the bearer-auth and SigV4-
signed paths; SigV4 still signs against `baseHeaders` (which already
contained content-type) so the signature matches what the helper
ultimately sends.

Net change: -73 / +86 (+13 in shared.ts mostly JSDoc; -86 across the
six adapters). The `HttpClientRequest` and `InvalidRequestError`
imports are dropped from the five SSE adapters and from Bedrock since
they're no longer referenced directly.

Verified: `bun typecheck` clean, 106 pass / 0 fail / 0 skip
(unchanged).
2026-05-01 08:12:34 -04:00
Kit Langton 339db0e885 docs(llm): document ProviderShared helpers and framing seam
Update the adapter authoring guide to reflect the dedupe pass:

- Generalize the `parse` bullet from `ProviderShared.sse` to
  `ProviderShared.framed` and call out the two framing dialects
  in use today (SSE for OpenAI/Anthropic/Gemini/compat, AWS event
  stream for Bedrock).
- Spell out that `framed`'s `framing` parameter is the seam for
  new wire formats; the rest of the pipeline is shared.
- New 'Shared adapter helpers' subsection enumerating the
  `ProviderShared` exports a new adapter author should reach for
  before hand-rolling: `framed`, `sse`, `sseFraming`, `joinText`,
  `parseToolInput`, `parseJson`, `chunkError`.
- Closing nudge: lift 3-5 line repeats into ProviderShared rather
  than copy them between adapters.

Doc-only — no code or test changes.
2026-05-01 08:12:34 -04:00
Kit Langton fa2a5d1fdb feat(opencode): convert native LLM message history 2026-05-01 08:12:34 -04:00
Kit Langton 3a94622e76 refactor(llm): dedupe adapter scaffolding into ProviderShared
Promote three repeated patterns out of individual adapters into
ProviderShared so a fifth or sixth adapter doesn't write the same
glue code over again.

ProviderShared.joinText(parts) — replaces the per-adapter `text()`
helper that joined an array of parts with newlines. Used by OpenAI
Chat (system content, user text, assistant text), OpenAI Responses
(system content), and Gemini (systemInstruction). The dead copies in
Anthropic Messages and Bedrock are gone.

ProviderShared.parseToolInput(adapter, name, raw) — replaces the
identical `parseJson(adapter, raw || "{}", \`Invalid JSON input
for <adapter> tool call <name>\`)` invocation in finishToolCall
across Anthropic, OpenAI Chat, OpenAI Responses, and Bedrock. Uniform
error message and the empty-string-to-"{}" fallback handled in one
place.

ProviderShared.framed(...) — generalizes the existing `sse()` helper
so the protocol-specific framing layer is pluggable. The shared
shape is bytes → frames → chunk → (state, events) with mapError /
mapEffect / mapAccumEffect / catchCause as the spine; framing is
the only varying step.

ProviderShared.sseFraming — the SSE-specific framing implementation
(decodeText + Sse.decode + filter [DONE]). The existing `sse()`
helper now delegates to `framed` with this framing, keeping the
adapter API surface identical.

Bedrock's parseStream — collapses to a single `ProviderShared.framed`
call with its own `eventStreamFraming` step. The cursor-based byte
buffer + AWS event-stream codec live as inputs to framed; everything
else is shared with the SSE adapters. Bedrock now has the same
`catchCause → streamError` terminal-error normalization that SSE
adapters have (it was missing before this refactor).

Net effect across the llm package: -66 lines / +114 lines but the
+114 is mostly JSDoc on the new helpers; adapter implementations
shrink. A future protocol (Bedrock InvokeModel, Vertex Gemini binary
streaming, etc.) plugs in by supplying its `framing` step.

Verified: `bun typecheck` clean, 106 pass / 0 fail / 0 skip
(unchanged from before the refactor).
2026-05-01 08:12:34 -04:00
Kit Langton 778b1762b0 feat(opencode): convert native LLM tool definitions 2026-05-01 08:12:34 -04:00
Kit Langton bab2fbc7f6 refactor(llm): simplify Bedrock Converse adapter after review
Cleanup of the Bedrock adapter (ba1705d) following parallel review
passes for code reuse, code quality, and efficiency.

- Drop dead `text` join helper and unused `TextPart` import.
- Schema-validate `model.native.aws_credentials` instead of seven
  manual `typeof` guards in `credentialsFromInput`. Removes the
  unsafe `as Record<string, unknown>` cast and fixes the dead
  `native?.region` fallback (the `model()` constructor only writes
  `aws_region`).
- Skip the JSON.parse → JSON.stringify → Schema.fromJsonString triple
  round-trip in the frame consumer. The eventstream codec already
  hands us a UTF-8 payload; parse once and feed the wrapped object
  directly to `Schema.decodeUnknownSync(BedrockChunk)`.
- Replace O(n²) buffer concat in `consumeFrames` with a cursor-based
  state `{ buffer, offset }`. Compaction happens once per network
  chunk via `appendChunk` instead of per frame; frame slicing is
  zero-copy via `subarray`. Bounded buffer growth regardless of
  stream length.
- Rename `ParserState.finishReason` → `pendingStopReason` (raw
  string) and defer the `mapFinishReason` call to the single emit
  site, plus the `onHalt` fallback. Tightens the helper's signature
  to `(reason: string)` so the chunk-typed `messageStop.stopReason`
  flows through without the optional widening.
- Restructure `signRequest` to take an object parameter (was four
  positional args), and replace the manual `forEach`-into-record with
  `Object.fromEntries(signed.headers.entries())`.
- Inline single-use `status` and `useTools` variables.
- Widen `fixedResponse` to accept `ConstructorParameters<Response>[0]`
  so binary fixtures (`Uint8Array`, streams) flow without casts. The
  Bedrock test's `fixedBytes` helper now wraps it cleanly.
- Tidy `captureResponseBody` into a ternary returning the union shape
  directly so the call site spreads the captured object without
  reaching for `bodyEncoding` explicitly.

Verified: `bun typecheck` clean, 106 pass / 0 fail / 0 skip
(unchanged from before the refactor).
2026-05-01 08:12:34 -04:00
Kit Langton 0da7d8a2a1 feat(opencode): add native LLM request builder 2026-05-01 08:12:33 -04:00
Kit Langton 769d6123d5 feat(llm): add Bedrock Converse adapter
Implements the AWS Bedrock Converse streaming protocol as the 5th
first-class adapter in @opencode-ai/llm. Single `bedrock-converse`
adapter covers all underlying models (Anthropic, Llama, Mistral,
Cohere, Nova, Titan) since Converse is uniform.

Wire format: messages with text / reasoning / toolUse / toolResult
content blocks, system blocks, inferenceConfig, toolConfig with
toolSpec + toolChoice. Image / document / cache-point content types
are still TODO.

Streaming: AWS event stream binary framing via @smithy/eventstream-codec.
Each frame is decoded then dispatched by `:event-type` header into
the chunk schema. Bedrock splits the finish across `messageStop`
(reason) and `metadata` (usage) — the parser stashes the reason and
emits a single consolidated `request-finish` event when metadata
arrives, with an `onHalt` fallback for truncated streams.

Auth: two paths. Bearer API key (newer) when the consumer sets
`model.headers.authorization = 'Bearer <key>'`. SigV4 signing via
aws4fetch otherwise — credentials live on `model.native.aws_credentials`
and are signed at `toHttp` time so STS-vended tokens are picked up
when the consumer rebuilds the model. The adapter rejects requests
with neither auth path with a clear InvalidRequestError.

Routing: `@ai-sdk/amazon-bedrock` lowers to `bedrock-converse` via
the new `AmazonBedrock` provider routing module; the OpenCode
`llm-bridge.ts` registers it.

Cassette format: response bodies under
`application/vnd.amazon.eventstream` and `application/octet-stream`
content types are now stored as base64 with `bodyEncoding: 'base64'`
on the response snapshot — text round-tripping mangled the CRC32
fields in event-stream frames. Existing cassettes (SSE/JSON) omit
the field and decode as text unchanged.

Tests: 11 deterministic fixtures (prepare / lower messages / lower
tool config / decode text+usage / decode tool calls / decode
reasoning / decode throttling exception / auth path validation /
SigV4 plumbing) + 2 recorded cassettes against live Bedrock
(`us.amazon.nova-micro-v1:0` in us-east-1) for streaming text and
streaming tool calls.

AGENTS.md: documents the Bedrock auth model, binary cassette format,
and updates the protocol coverage / cassette backlog.

Deps: @smithy/eventstream-codec, @smithy/util-utf8, aws4fetch (~40KB
combined; matches AI SDK's approach).
2026-05-01 08:12:33 -04:00
Kit Langton 6c887b0faa refactor(llm): brand provider and model identifiers 2026-05-01 08:12:33 -04:00
Kit Langton 4e3f678b24 feat(llm): add provider-routed adapter composition 2026-05-01 08:12:33 -04:00
Kit Langton e1c6bf92fb feat(llm): provider-executed tool pass-through
Add a `providerExecuted: boolean` flag to `tool-call` and `tool-result`
events plus the persisted `ToolResultPart`. When set, the tool runtime
skips client dispatch (the provider already executed the tool) and folds
both events into the assistant message so the next round's history
carries the call + result for context.

Anthropic: decode `server_tool_use` blocks and the three server tool
result block types (`web_search_tool_result`, `code_execution_tool_result`,
`web_fetch_tool_result`) into `tool-call` / `tool-result` events with
`providerExecuted: true`. Round-trip the same parts back into the
provider when the assistant message is replayed in subsequent requests.
Result block error payloads (`*_tool_result_error`) surface as
`result.type === "error"`.

OpenAI Responses: decode hosted tool items emitted via
`response.output_item.done` (`web_search_call`, `file_search_call`,
`code_interpreter_call`, `computer_use_call`, `image_generation_call`,
`mcp_call`, `local_shell_call`) as `tool-call` + `tool-result` pairs
with `providerExecuted: true`. Each tool's input fields are pulled out
explicitly; the full item is passed through as the result payload so
consumers can read outputs / sources / status without re-decoding.

Tool runtime: extend the dispatch decision so provider-executed
tool-calls bypass the handler lookup, and tool-result events with
`providerExecuted: true` are appended to the assistant content for
round-trip rather than being treated as a separate tool message.

Tests: 7 new deterministic fixtures cover Anthropic decode (success +
error result + round-trip + unknown server tool name), OpenAI Responses
decode (web_search_call, code_interpreter_call), and tool-runtime
skip-dispatch.

AGENTS.md updates the runtime section to describe pass-through behavior
and notes the transport-agnostic design that keeps a future WebSocket
adapter (e.g. OpenAI Codex backend) as a sibling rather than a core
rewrite.
2026-05-01 08:11:29 -04:00
Kit Langton b5ca62d1ea test(llm): record OpenAI Chat tool-loop cassette
Captures both model rounds of the typed ToolRuntime tool loop into a
single multi-interaction cassette: round 1 carries the user prompt and
returns a get_weather tool call; round 2 carries the assistant tool call
plus tool result and returns a final answer.

Verifies the multi-interaction cassette infrastructure end-to-end against
a real provider.
2026-05-01 08:11:29 -04:00
Kit Langton ca8d700a14 feat(llm): support multi-interaction cassettes with sequential matcher
The cassette layer already stored interactions in an array, but replay
always used find-first structural matching and cassettes were written
as one minified JSON line. That makes tool-loop and retry recordings
unworkable: identical requests collapse to one response, and large
recordings are unreadable on review.

- Add `sequentialMatcher` for position-based dispatch so identical
  retries map to recorded responses in order via an internal cursor.
- Pretty-print cassette JSON on write and reformat existing fixtures so
  multi-interaction diffs stay reviewable.
- Add deterministic `record-replay.test.ts` covering default vs
  sequential dispatch and cursor exhaustion.
- Add an OpenAI Chat tool-loop recorded test scaffold gated behind
  `OPENAI_API_KEY` so a single `RECORD=true` run captures every
  model round of the loop into one cassette file.
- Update AGENTS.md to document multi-interaction cassettes and the
  matcher options, and mark the cassette ergonomics TODO complete.
2026-05-01 08:11:29 -04:00
Kit Langton ca198f739e refactor(llm): cache tool codecs and tighten ToolRuntime types
Simplify pass after the typed ToolRuntime initial drop. Findings from a
parallel review (code reuse + quality + perf):

src/tool.ts
- Tool now carries memoized decode/encode codecs and a precomputed
  ToolDefinition, derived once at tool() construction time. The runtime no
  longer rebuilds Schema closures or JSON Schema docs per call/per run.
- Constrains parameters/success to Schema.Codec<T, any, never, never> so
  the codecs have no service requirements. Drops the 'as unknown as' casts
  the runtime needed previously.
- Fixes a latent bug: schemas with $ref now correctly emit $defs on
  ToolDefinition.inputSchema (toJsonSchemaDocument's definitions were
  silently dropped before).

src/tool-runtime.ts
- Uses LLMRequest constructor instead of 'as LLMRequest' casts.
- Default tool dispatch concurrency is 10 (was 'unbounded'); exposed via
  RunOptions.concurrency. Unbounded is still available for handlers that
  do not share a saturable resource.
- Drops dead 'usage' state, the single-use Dispatched interface, and the
  DEFAULT_MAX_STEPS constant per the inline-when-used style rule.
- accumulate() now factors text-delta and reasoning-delta into one helper.

test/lib/openai-chunks.ts (new)
- Shared deltaChunk / usageChunk / toolCallChunk / finishChunk helpers.

test/lib/http.ts
- scriptedResponses moved here from tool-runtime.test.ts so future
  multi-step adapter tests can reuse it. Also picks up parallel work that
  swapped HandlerInput to a 'respond' callback for cleaner Response
  construction.

test/tool-runtime.test.ts
- Uses LLMEvent.guards for typed event filtering instead of cast-and-check.
- Concurrent test now uses sseEvents + deltaChunk instead of a hand-rolled
  body string.

Includes parallel callsite updates in test/adapter.test.ts and
test/provider/openai-compatible-chat.test.ts that adopt the 'respond' API
in lib/http.ts.
2026-05-01 08:11:29 -04:00
Kit Langton 6a7735e14c test(llm): cover OpenAI-compatible Chat parity 2026-05-01 08:11:29 -04:00
Kit Langton 3a2cb7f8ac feat(llm): add typed ToolRuntime
Schema-first, Effect-first tool loop:

- 'tool({ description, parameters, success, execute })' constructs a fully
  typed Tool. parameters and success are Effect Schemas; execute is typed
  against them and returns Effect<Success, ToolFailure>. Handler dependencies
  are closed over at construction time so the runtime never sees per-tool
  services.
- 'ToolRuntime.run(client, { request, tools, maxSteps?, stopWhen? })' streams
  the model, decodes tool-call inputs against parameters, dispatches to the
  matching handler, encodes results against success, emits tool-result events,
  appends assistant + tool messages, and re-streams. Stops on non-tool-calls
  finish, maxSteps, or stopWhen.
- Three recoverable error paths emit tool-error events so the model can
  self-correct: unknown tool name, input fails parameters Schema, handler
  returns ToolFailure. Defects fail the stream.
- 'ToolFailure' added to the schema and exported as the single forced error
  channel for handlers.
- Tool definitions on the LLMRequest are derived via toJsonSchemaDocument so
  consumers don't write JSON Schema by hand.

8 deterministic fixture tests cover the loop, errors, maxSteps, stopWhen, and
parallel tool calls in one step.
2026-05-01 08:11:29 -04:00
Kit Langton b4a7cf638f feat(llm): add OpenAI-compatible provider helpers 2026-05-01 08:11:29 -04:00
Kit Langton 0cc992fc7c feat(llm): add OpenAI-compatible Chat adapter 2026-05-01 08:11:29 -04:00
Kit Langton ca29f8a6ef test(llm): cover provider-error events and HTTP sad paths
Locks down the error contract before OpenCode integration:
- mid-stream provider errors (Anthropic 'event: error', OpenAI Responses
  'type: error') surface as 'provider-error' LLMEvents
- HTTP 4xx responses fail with ProviderRequestError before stream parsing
  begins (the executor contract)

Anthropic already had both. Adds:
- OpenAI Responses: provider-error fixture, code-fallback fixture, HTTP 400
- OpenAI Chat: HTTP 400 sad path
- AGENTS.md TODO refreshed; live recordings of provider errors still pending
2026-05-01 08:11:29 -04:00
Kit Langton afe3990f27 refactor(llm): convert lowerToolChoice helpers to yieldable form
Per the package style guide, sync if/return functions that need to fail
should yield the error directly via Effect.gen rather than ladder
Effect.fail / Effect.succeed across every branch.

Touches all four adapters' tool-choice lowering. The naming-required
validation now reads as 'guard, then return' rather than embedded in a
chain of monadic returns. Behavior unchanged.
2026-05-01 08:11:29 -04:00
Kit Langton 8a4699e8e7 refactor(llm): drop vestigial Chunk type and raise step
Every adapter's parse already produces LLMEvents (via the process callback in
the shared sse helper), and every raise was Stream.make(event). The Chunk type
parameter, the raise field, the RaiseState interface, and the Stream.flatMap
raise step in client.stream were all pure overhead.

- Adapter contract shrinks from <Draft, Target, Chunk> to <Draft, Target>.
- All four adapters drop their raise: (event) => Stream.make(event) line.
- client.stream skips the no-op flatMap.
- AGENTS.md adapter section reflects the simpler contract.
2026-05-01 08:11:29 -04:00
Kit Langton 74b2e5781c refactor(llm): remove unused SSE invalid chunk option 2026-05-01 08:11:28 -04:00
Kit Langton 6573673875 docs(llm): mark Responses/Anthropic/Gemini done and outline OpenCode integration
Updates the AGENTS.md TODO list:
- mark Responses, Anthropic, and Gemini adapter coverage as done
- mark the Gemini schema sanitizer port as done
- add concrete next-step items for OpenCode integration: ModelRef bridge,
  request bridge, provider-quirk patches, request/stream parity tests, and
  a flagged rollout against existing session/llm.test.ts cases
- add OpenAI-compatible Chat, Bedrock Converse, and Vertex routing as
  outstanding adapter/dispatch decisions
2026-05-01 08:11:28 -04:00
Kit Langton 3561938e41 feat(llm): port Gemini tool-schema sanitizer as a patch
Gemini rejects integer enums, dangling required fields, untyped arrays, and
object keywords on scalar schemas. The sanitizer was previously a divergent
copy in OpenCode; this lands it in the package as a tool-schema patch with
deterministic tests and selects it for Gemini-protocol or Gemini-named models.

Also tightens the Gemini test suite: covers tool-choice none, drops the
tool-input-delta assertion that Gemini does not actually emit, and confirms
total usage stays undefined when only thoughtsTokenCount arrives.
2026-05-01 08:11:28 -04:00
Kit Langton e476b63a28 refactor(llm): yieldable parser errors and linear runFold
- shared sse helper now expects Effectful decodeChunk and process callbacks,
  so adapter parsers can be Effect.gen and yield typed ProviderChunkError
  instead of throwing across the sync mapAccum boundary.
- parseJson returns Effect<unknown, ProviderChunkError> via Effect.try,
  matching the package style guide on yieldable errors.
- OpenAI Chat finalizes accumulated tool inputs eagerly when finish_reason
  arrives, surfacing JSON parse failures at the boundary instead of at halt.
  onHalt stays sync and just emits from state.
- generate's runFold reducer now mutates the accumulator instead of
  reallocating the events array on every chunk, dropping O(n^2) growth on
  long streams.
2026-05-01 08:11:28 -04:00
Kit Langton 850eeae24c test(llm): cover Gemini stream edge cases 2026-05-01 08:11:28 -04:00
Kit Langton 8d97b38983 feat(llm): add Gemini adapter 2026-05-01 08:11:28 -04:00
Kit Langton 9a05675200 refactor(llm): share provider stream parsing 2026-05-01 08:11:28 -04:00
Kit Langton 0f4e54d6e8 feat(llm): add Anthropic Messages adapter 2026-05-01 08:11:28 -04:00
Kit Langton aec6c5983d feat(llm): add OpenAI Responses adapter 2026-05-01 08:11:28 -04:00
Kit Langton 18d618d051 test(llm): harden cassette matching and add streaming edge-case coverage
- Structurally match recorded requests by canonical JSON so non-deterministic
  field ordering doesn't break replay.
- Pluggable header allow-list and body redaction hook on the record/replay
  layer, so adapters with non-default auth (Anthropic, Bedrock) can plug in
  without touching this file.
- Move the cassette-name dedupe set inside recordedTests() so two describe
  files using different prefixes can run in parallel.
- Replace inline SSE template literals and per-file HTTP layers with shared
  test/lib helpers (sseEvents, fixedResponse, dynamicResponse, truncatedStream).
- Tighten recorded-test assertions to exact text and usage so adapter parser
  regressions surface immediately instead of passing fuzzy length>0 checks.
- Add cancellation and mid-stream transport-error tests for the OpenAI Chat
  adapter.
- Add cross-phase patch tests that verify each phase sees an updated
  PatchContext and that same-order patches sort deterministically by id.
2026-05-01 08:11:28 -04:00
Kit Langton 412a1bec44 test(llm): clean Effect test utilities 2026-05-01 08:11:28 -04:00
Kit Langton 04468304e7 refactor(llm): simplify adapter execution API 2026-05-01 08:11:28 -04:00
Kit Langton ca9e0cfa3c test(llm): record OpenAI tool result flow 2026-05-01 08:11:28 -04:00
Kit Langton f02652353e test(llm): add provider patch coverage 2026-05-01 08:11:28 -04:00
Kit Langton 1e0f6ee242 feat(llm): add adapter registry ergonomics 2026-05-01 08:11:28 -04:00
Kit Langton 36ab9fa584 docs(llm): add package todo list 2026-05-01 08:11:27 -04:00
Kit Langton d96bf0d566 feat(llm): add OpenAI Chat adapter 2026-05-01 08:11:27 -04:00
Kit Langton 79683710c0 feat(llm): move core to package 2026-05-01 08:11:27 -04:00
Kit Langton edd176c490 feat(llm): add initial patch API 2026-05-01 08:11:27 -04:00
Kit Langton 16ddf5f559 fix(session): use finite archived timestamp schema (#25275) 2026-05-01 11:57:03 +00:00
Kit Langton 8c79c58c4d refactor: rename workspace adapters (#25272) 2026-05-01 07:36:52 -04:00
luo jiyin 97ed9ba624 fix: correct documentation typos (#25260) 2026-05-01 12:05:06 +02:00
Simon Klee a6b6395c8a fix(tui): gate logo subpixel rendering on truecolor support (#25265) 2026-05-01 11:33:44 +02:00
opencode 21f8027ef7 sync release versions for v1.14.31
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
docs-update / update-docs (push) Has been cancelled
deploy / deploy (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
containers / build (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
stats / stats (push) Has been cancelled
daily-pr-recap / pr-recap (push) Has been cancelled
daily-issues-recap / daily-recap (push) Has been cancelled
close-issues / close (push) Has been cancelled
close-stale-prs / close-stale-prs (push) Has been cancelled
2026-05-01 06:13:48 +00:00
Brendan Allan a5aa72bd7d fix: update provider store after loading providers in bootstrap (#25236) 2026-05-01 13:39:22 +08:00
Aiden Cline 563177c6ac fix: fix issue if tool returned image and empty text and it caused api errors (#25241) 2026-05-01 00:13:03 -05:00
opencode-agent[bot] 4eae8ec037 chore: generate 2026-05-01 04:21:51 +00:00
Aiden Cline 08895c396e docs: fix tui and keybinds documentation (#25233) 2026-04-30 23:20:54 -05:00
opencode-agent[bot] 4e451a4b0f chore: update nix node_modules hashes 2026-05-01 04:07:58 +00:00
Brendan Allan 163290bcf0 desktop: sentry integration (#15300)
Co-authored-by: Jay V <air@live.ca>
2026-05-01 11:56:31 +08:00
Aiden Cline c68c33d4fe docs: remove deprecated modes.mdx pages (#25227) 2026-04-30 22:49:32 -05:00
Dax Raad 3615d8e226 core: clarify that temp directory already exists for AI agents
The bash tool description now explicitly states that the temp directory has already been created and exists, preventing agents from unnecessarily trying to create it before use.
2026-04-30 23:48:48 -04:00
Dax 2283979199 Preapprove agent tmp directory access (#25226) 2026-04-30 23:47:15 -04:00
Aiden Cline 33f7f593ee fix: tui list jank issue (#25219) 2026-04-30 22:45:41 -05:00
opencode-agent[bot] 461e7345b3 chore: update nix node_modules hashes 2026-05-01 03:32:55 +00:00
opencode-agent[bot] 6bd91c68e8 chore: generate 2026-05-01 03:22:36 +00:00
Dax Raad ff55a40749 core: remove @effect/language-service plugin and optimize hot path type performance
- Removed @effect/language-service from both packages/core and packages/opencode tsconfig files and dependencies

- Wrapped mergeDeep calls in config loading and LLM streaming to avoid expensive remeda conditional merge type instantiations in hot paths

- Narrowed Drizzle migrate() overload signature to avoid expensive variance checks during database initialization

These changes reduce TypeScript type-checking overhead and improve startup and runtime performance for config loading, LLM streaming, and database migrations.
2026-04-30 23:21:05 -04:00
opencode-agent[bot] 8b56d77ea1 chore: generate 2026-05-01 03:02:15 +00:00
Kit Langton dd3aa96730 test(httpapi): cover more safe GET parity (#25217) 2026-04-30 23:01:11 -04:00
Kit Langton 8b56d1712f refactor(session): pass project to list (#25215) 2026-04-30 23:00:59 -04:00
Kit Langton 3c24d22d42 fix(httpapi): omit absent optional response fields (#25214) 2026-05-01 02:38:32 +00:00
Kit Langton 4c70ea28d2 fix(tui): scope Zed editor context to containing workspaces (#25211) 2026-04-30 22:33:39 -04:00
Kit Langton 5ba68a28c0 refactor(httpapi): scope async prompt fiber (#25213) 2026-04-30 22:33:02 -04:00
opencode-agent[bot] bce4def2db chore: generate 2026-05-01 02:26:56 +00:00
Kit Langton 3544ea0244 refactor(httpapi): drop session prompt bridge (#25210) 2026-04-30 22:25:52 -04:00
opencode-agent[bot] 6434918794 chore: generate 2026-05-01 01:46:55 +00:00
Kit Langton 5984d917dc refactor(session): yield instance context in system prompt (#25207) 2026-04-30 21:45:48 -04:00
Kit Langton c2a97a7a6c refactor(file): yield instance context in watcher (#25205) 2026-04-30 21:45:21 -04:00
Kit Langton a083c88e87 refactor(sync): capture instance context for publish (#25206) 2026-04-30 21:45:02 -04:00
Kit Langton ce3b0988c4 refactor(project): yield instance context in bootstrap (#25204) 2026-04-30 21:44:52 -04:00
Kit Langton e8a194a2bb test(effect): stabilize runner active shell check (#25203) 2026-04-30 21:36:19 -04:00
Kit Langton 8aa8798e07 refactor(session): yield instance context in llm (#25200) 2026-04-30 21:29:28 -04:00
opencode-agent[bot] 6d4629b566 chore: generate 2026-05-01 01:28:37 +00:00
OpeOginni a9d399699e fix(desktop): Prevent Model response Interruption when opening settings dialog (#25114) 2026-05-01 01:27:38 +00:00
Kit Langton bc805b3001 Pass CORS options to HttpApi backend (#25201) 2026-04-30 21:26:32 -04:00
Kit Langton 668d77bb4e refactor(tool): yield InstanceState context (#25199) 2026-04-30 21:01:06 -04:00
Kit Langton 5c2e06f353 Document HttpApi route patterns (#25188) 2026-04-30 20:48:14 -04:00
Kit Langton a499fe2b17 refactor(tool/read): yield InstanceState.context instead of reading ALS (#25183) 2026-04-30 20:33:04 -04:00
Kit Langton 451650b584 refactor(httpapi): preserve typed errors in session prompt handlers (#25181) 2026-04-30 20:04:00 -04:00
opencode-agent[bot] 1b76bec0e2 chore: generate 2026-05-01 00:03:55 +00:00
Kit Langton 96f4da1e1d Serve instance events through HttpApiBuilder (#25182) 2026-04-30 20:02:46 -04:00
opencode-agent[bot] 96a0dd6b04 chore: generate 2026-04-30 23:37:58 +00:00
Kit Langton 2dd1f2d453 Avoid request-time HttpApi layer provisioning (#25179) 2026-04-30 19:36:57 -04:00
opencode-agent[bot] 510f01674a chore: generate 2026-04-30 23:29:58 +00:00
Kit Langton e3134a2a99 refactor(session): align prompt input types with their schemas (#25178) 2026-04-30 19:28:46 -04:00
opencode-agent[bot] 8805104b8d chore: generate 2026-04-30 23:25:10 +00:00
Kit Langton fc155e9fc5 Build HttpApi UI route from services (#25177) 2026-04-30 19:24:10 -04:00
opencode-agent[bot] 3aaac0098e chore: generate 2026-04-30 23:06:57 +00:00
Sewer. a12333310f fix(provider): split providerOptions key on dot for openai-compatible, openai, and anthropic providers (#25145) 2026-04-30 18:05:56 -05:00
opencode-agent[bot] 247284b9af chore: generate 2026-04-30 22:51:09 +00:00
Kit Langton e0305e47f3 Protect HttpApi web UI fallback with auth (#25169) 2026-04-30 18:49:54 -04:00
Kit Langton 76a0f0f619 docs(httpapi): update migration spec to current state (#25173) 2026-04-30 18:41:27 -04:00
Aiden Cline 560baae15d fix: ensure user config takes precendence over plugin hooks for model resolution (#25167) 2026-04-30 17:15:56 -05:00
Kit Langton 5518ecaefe Fix HttpApi web UI fallback (#25163) 2026-04-30 17:43:18 -04:00
opencode-agent[bot] 924ba97055 chore: generate 2026-04-30 21:04:24 +00:00
Aiden Cline b80f52f8ad tweak: adjust codex plugin to use the models hook (#25157) 2026-04-30 16:03:07 -05:00
Kit Langton feb275d08b Remove covered workspace websocket todo (#25161) 2026-04-30 20:58:08 +00:00
Kit Langton fbcbd24063 Add SyncEvent service (#25158) 2026-04-30 16:45:26 -04:00
Kit Langton 3250b814ce Fix HttpApi raw route authorization (#25154) 2026-04-30 19:55:20 +00:00
Kit Langton 0e9d9282c6 Refactor workspace service boundaries (#25152) 2026-04-30 15:34:37 -04:00
Kit Langton b315a70773 test: use Effect test helper for agent colors (#25051) 2026-04-30 15:14:25 -04:00
Kit Langton cedff6fb89 Isolate TUI thread cwd resolution test (#25147) 2026-04-30 15:10:30 -04:00
Kit Langton 87cd9446d8 test: use testEffect for plugin triggers (#25053) 2026-04-30 14:24:53 -04:00
Kit Langton f4ce240a2e Use PTY service directly in HTTP routes (#25138) 2026-04-30 14:24:43 -04:00
Kit Langton 320527a3e4 Support multiple Zed selections in TUI context (#25140) 2026-04-30 14:15:50 -04:00
Kit Langton 19271fca2d Use workspace service in HTTP routes (#25139) 2026-04-30 13:57:25 -04:00
Kit Langton feeebbe7d4 Preserve workspace context in session HTTP routes (#25136) 2026-04-30 13:53:26 -04:00
Kit Langton f384675c01 test: use Effect test helper for run-service (#25048) 2026-04-30 17:05:10 +00:00
Kit Langton ec3ab4a00c test: use testEffect for retry policy (#25050) 2026-04-30 12:55:33 -04:00
Kit Langton e4ac936eb9 test: use testEffect for plugin workspace adaptor (#25052) 2026-04-30 12:54:53 -04:00
Kit Langton 79e23b7eb9 test: use testEffect for instance state (#25115) 2026-04-30 12:53:13 -04:00
Kit Langton 92e80b4660 test: use Effect test helper for app runtime logger (#25049) 2026-04-30 12:52:29 -04:00
Kit Langton ce63ca4d7a test: use testEffect for system prompt test (#25047) 2026-04-30 12:51:32 -04:00
Kit Langton fef7981942 test: use Effect runtime in runner deadlock case (#25045) 2026-04-30 12:45:30 -04:00
Aiden Cline ffe0314c47 fix: ensure disabling OPENCODE_DISABLE_CLAUDE_CODE_SKILLS doesnt disable external skills too (#25123) 2026-04-30 11:15:53 -05:00
opencode-agent[bot] 375444a149 chore: update nix node_modules hashes 2026-04-30 15:48:28 +00:00
Kit Langton 65c15afe9f test: use testEffect for instruction tests (#25046) 2026-04-30 11:48:13 -04:00
opencode-agent[bot] 8f57a2a462 chore: generate 2026-04-30 15:46:04 +00:00
James Long 53e9cac383 refactor(core): convert control-plane workspace to Effect (#25018) 2026-04-30 11:44:58 -04:00
Sebastian fe0c182747 upgrade opentui to 0.2.0 (#24810) 2026-04-30 17:33:54 +02:00
opencode-agent[bot] 29b1060c67 chore: generate 2026-04-30 15:08:03 +00:00
Kit Langton dddfcbf0d8 test: port instance HttpApi path/vcs read coverage to Effect 2026-04-30 11:07:00 -04:00
OpeOginni 62e1335388 fix(opencode): allow oc://renderer origin in cors middleware (#25099) 2026-04-30 12:11:42 +00:00
Brendan Allan 908e28175f fix: invert *_ready getters to fix server status indicator (#25077) 2026-04-30 15:10:39 +08:00
Brendan Allan 3398fd7719 feat(httpapi): add CORS middleware to instance routes (#25074) 2026-04-30 07:06:17 +00:00
Luke Parker 9bddf7f3ef fix app crash restoring messages without model (#25062)
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
docs-update / update-docs (push) Has been cancelled
deploy / deploy (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
stats / stats (push) Has been cancelled
daily-pr-recap / pr-recap (push) Has been cancelled
daily-issues-recap / daily-recap (push) Has been cancelled
close-issues / close (push) Has been cancelled
close-stale-prs / close-stale-prs (push) Has been cancelled
2026-04-30 14:44:53 +10:00
Dax Raad 8ba374fefa ci: enable sourcemaps for beta releases
Generate linked sourcemaps when building beta releases to help users
debug issues with readable stack traces.
2026-04-30 00:42:22 -04:00
Aiden Cline 3ef0aaf768 tweak: make azure onboarding ux a bit better (#25057) 2026-04-29 23:35:59 -05:00
Tommy D. Rossi d7701dbfb6 fix(opencode): preserve external_dir and deny parent permissions in task child sessions (#23290) 2026-04-29 22:06:29 -05:00
Kit Langton c49bf0b402 test: cover ConfigService helper (#25042) 2026-04-30 02:41:59 +00:00
Kit Langton cee9610d26 refactor: use Effect config for HttpApi authorization (#25035) 2026-04-29 22:22:32 -04:00
Kit Langton 38adc13295 test: cover HttpApi authorization middleware (#25033) 2026-04-29 21:34:52 -04:00
589 changed files with 54155 additions and 23954 deletions
-1
View File
@@ -1,6 +1,5 @@
name: Bug report
description: Report an issue that should be fixed
labels: ["bug"]
body:
- type: textarea
id: description
@@ -1,6 +1,5 @@
name: 🚀 Feature Request
description: Suggest an idea, feature, or enhancement
labels: [discussion]
title: "[FEATURE]:"
body:
-1
View File
@@ -1,6 +1,5 @@
name: Question
description: Ask a question
labels: ["question"]
body:
- type: textarea
id: question
-1
View File
@@ -11,6 +11,5 @@ MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr
simonklee
-40
View File
@@ -1,40 +0,0 @@
# Vouched contributors for this project.
#
# See https://github.com/mitchellh/vouch for details.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: platform:username (e.g., github:user).
# - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle.
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-carycooper777
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
dmtrkovalenko
edemaine
fahreddinozcan
-florianleibert
fwang
iamdavidhill
jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
rubdos
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
-terisuke
thdxr
-toastythebot
-170
View File
@@ -1,170 +0,0 @@
name: daily-issues-recap
on:
schedule:
# Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving)
- cron: "0 23 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
daily-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily issues recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
# Get today's date range
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather today's issues
Search for all OPEN issues created today (${TODAY}) using:
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) issues only.
STEP 2: Analyze and categorize
For each issue created today, categorize it:
**Severity Assessment:**
- CRITICAL: Crashes, data loss, security issues, blocks major functionality
- HIGH: Significant bugs affecting many users, important features broken
- MEDIUM: Bugs with workarounds, minor features broken
- LOW: Minor issues, cosmetic, nice-to-haves
**Activity Assessment:**
- Note issues with high comment counts or engagement
- Note issues from repeat reporters (check if author has filed before)
STEP 3: Cross-reference with existing issues
For issues that seem like feature requests or recurring bugs:
- Search for similar older issues to identify patterns
- Note if this is a frequently requested feature
- Identify any issues that are duplicates of long-standing requests
STEP 4: Generate the recap
Create a structured recap with these sections:
===DISCORD_START===
**Daily Issues Recap - ${TODAY}**
**Summary Stats**
- Total issues opened today: [count]
- By category: [bugs/features/questions]
**Critical/High Priority Issues**
[List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers]
**Most Active/Discussed**
[Issues with significant engagement or from active community members]
**Trending Topics**
[Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature']
**Duplicates & Related**
[Issues that relate to existing open issues]
===DISCORD_END===
STEP 5: Format for Discord
Format the recap as a Discord-compatible message:
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report
- Use hyperlinked issue numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/issues/1234>)
- Group related issues on single lines where possible
- Add emoji sparingly for critical items only
- HARD LIMIT: Keep under 1800 characters total
- Skip sections that have nothing notable (e.g., if no critical issues, omit that section)
- Prioritize signal over completeness - only surface what matters
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt
echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily recap to Discord"
-173
View File
@@ -1,173 +0,0 @@
name: daily-pr-recap
on:
schedule:
# Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving)
- cron: "0 22 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
pr-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily PR recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh pr*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather PR data
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
# Open PRs created today
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
# Open PRs with activity today (updated today)
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) contributions only.
STEP 2: For high-activity PRs, check comment counts
For promising PRs, run:
gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length'
IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts:
- copilot-pull-request-reviewer
- github-actions
STEP 3: Identify what matters (ONLY from today's PRs)
**Bug Fixes From Today:**
- PRs with 'fix' or 'bug' in title created/updated today
- Small bug fixes (< 100 lines changed) that are easy to review
- Bug fixes from community contributors
**High Activity Today:**
- PRs with significant human comments today (excluding bots listed above)
- PRs with back-and-forth discussion today
**Quick Wins:**
- Small PRs (< 50 lines) that are approved or nearly approved
- PRs that just need a final review
STEP 4: Generate the recap
Create a structured recap:
===DISCORD_START===
**Daily PR Recap - ${TODAY}**
**New PRs Today**
[PRs opened today - group by type: bug fixes, features, etc.]
**Active PRs Today**
[PRs with activity/updates today - significant discussion]
**Quick Wins**
[Small PRs ready to merge]
===DISCORD_END===
STEP 5: Format for Discord
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - surface what we might miss
- Use hyperlinked PR numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/pull/1234>)
- Include PR author: [#1234](<url>) (@author)
- For bug fixes, add brief description of what it fixes
- Show line count for quick wins: \"(+15/-3 lines)\"
- HARD LIMIT: Keep under 1800 characters total
- Skip empty sections
- Focus on PRs that need human eyes
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt
echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/pr_recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/pr_recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily PR recap to Discord"
+6
View File
@@ -36,3 +36,9 @@ jobs:
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: web@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
+8 -1
View File
@@ -88,7 +88,7 @@ jobs:
- name: Build
id: build
run: |
./packages/opencode/script/build.ts
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -494,6 +494,13 @@ jobs:
working-directory: packages/desktop-electron
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
- name: Package and publish
if: needs.version.outputs.release
-116
View File
@@ -1,116 +0,0 @@
name: vouch-check-issue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if issue author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.issue.user.login;
const issueNumber = context.payload.issue.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the issue
const body = 'This issue has been automatically closed.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);
-114
View File
@@ -1,114 +0,0 @@
name: vouch-check-pr
on:
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if PR author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.pull_request.user.login;
const prNumber = context.payload.pull_request.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to PR #${prNumber} from ${author}`);
@@ -1,38 +0,0 @@
name: vouch-manage-by-issue
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: read
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- uses: mitchellh/vouch/action/manage-by-issue@main
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+1
View File
@@ -3,6 +3,7 @@ node_modules
.worktrees
.sst
.env
.env.local
.idea
.vscode
.codex
+5
View File
@@ -0,0 +1,5 @@
# Fake secret-looking strings used by HTTP recorder redaction tests.
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71
+15 -112
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/minimax-m2.5
model: opencode/gpt-5.4-nano
color: "#44BA81"
tools:
"*": false
@@ -14,127 +14,30 @@ Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
## Labels
Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team.
### windows
Do not add labels to issues. Only assign an owner.
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows.
- Use if they mention WSL too
## Teams
#### perf
### TUI
Performance-related issues:
Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance.
- Slow performance
- High RAM usage
- High CPU usage
### Desktop / Web
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems.
#### desktop
### Core
Desktop app issues:
Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features.
- `opencode web` command
- The desktop app itself
### Inference
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
OpenCode Zen, OpenCode Go, and billing issues.
#### nix
### Windows
**Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs
Add if the issue requests better documentation or docs updates.
#### opentui
TUI issues potentially caused by our underlying TUI library:
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
**Do not** add for general TUI bugs.
When assigning to people here are the following rules:
Desktop / Web:
Use for desktop-labeled issues only.
- adamdotdevin
- iamdavidhill
- Brendonovich
- nexxeln
Zen:
ONLY assign if the issue will have the "zen" label.
- fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)
Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows.
+12 -68
View File
@@ -1,16 +1,14 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["kommander", "rekram1-node", "simonklee"],
core: ["kitlangton", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
@@ -38,79 +36,25 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `Use this tool to assign and/or label a GitHub issue.
description: `Use this tool to assign a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
.default([]),
team: tool.schema
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]])
.describe("The owning team"),
},
async execute(args) {
const issue = getIssueNumber()
const owner = "anomalyco"
const repo = "opencode"
const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (labels.includes("nix") && !nix) {
labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
}
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
const assignee = pick(TEAM[args.team])
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST",
body: JSON.stringify({ assignees: [assignee] }),
})
results.push(`Assigned @${assignee} to issue #${issue}`)
if (labels.length > 0) {
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ labels }),
})
results.push(`Added labels: ${labels.join(", ")}`)
}
return results.join("\n")
return `Assigned @${assignee} from ${args.team} to issue #${issue}`
},
})
+1 -1
View File
@@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
- Out-of-the-box LSP support
- Built-in opt-in LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
+207 -48
View File
@@ -29,12 +29,13 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
@@ -69,6 +70,7 @@
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -83,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +194,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.30",
"version": "1.14.33",
"bin": {
"opencode": "./bin/opencode",
},
@@ -226,10 +228,11 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
@@ -250,6 +253,7 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@sentry/vite-plugin": "catalog:",
"@tauri-apps/cli": "^2",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -259,7 +263,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -275,6 +279,8 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
@@ -303,7 +309,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -332,7 +338,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -346,9 +352,40 @@
"typescript": "catalog:",
},
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "0.0.0",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.25",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
"effect": "catalog:",
},
"devDependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.30",
"version": "1.14.33",
"bin": {
"opencode": "./bin/opencode",
},
@@ -390,6 +427,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -456,7 +494,6 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/script": "workspace:*",
@@ -491,7 +528,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -506,8 +543,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105",
"@opentui/core": ">=0.2.2",
"@opentui/solid": ">=0.2.2",
},
"optionalPeers": [
"@opentui/core",
@@ -526,7 +563,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -541,7 +578,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -576,7 +613,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -625,7 +662,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -685,10 +722,12 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
@@ -708,7 +747,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.57",
"effect": "4.0.0-beta.59",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -1069,8 +1108,6 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.57", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.57" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-gdjZPEP0QQg4qmI1vd+443kheeQZKytrjJIzCJncy6ZEpyk/SfrqeStLqLXdTRcms3IB0ls0vOV7KNq7YmBRVA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.57", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.57", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.57", "ioredis": "^5.7.0" } }, "sha512-la0xxPSAYOsY0d+uVxEBxok3jYB31iPQmIaZZRUj2SNWqcGGHJc6KorKtI8guqSLuv9FGZ255kBWXRbG6hMeeg=="],
@@ -1571,6 +1608,10 @@
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
@@ -1613,21 +1654,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
"@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1959,6 +2000,44 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="],
"@sentry-internal/feedback": ["@sentry-internal/feedback@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA=="],
"@sentry-internal/replay": ["@sentry-internal/replay@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg=="],
"@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.36.0", "", { "dependencies": { "@sentry-internal/replay": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ=="],
"@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.0", "", {}, "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ=="],
"@sentry/browser": ["@sentry/browser@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry-internal/feedback": "10.36.0", "@sentry-internal/replay": "10.36.0", "@sentry-internal/replay-canvas": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ=="],
"@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.6.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.6.0", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g=="],
"@sentry/cli": ["@sentry/cli@2.58.5", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.5", "@sentry/cli-linux-arm": "2.58.5", "@sentry/cli-linux-arm64": "2.58.5", "@sentry/cli-linux-i686": "2.58.5", "@sentry/cli-linux-x64": "2.58.5", "@sentry/cli-win32-arm64": "2.58.5", "@sentry/cli-win32-i686": "2.58.5", "@sentry/cli-win32-x64": "2.58.5" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg=="],
"@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.5", "", { "os": "darwin" }, "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ=="],
"@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw=="],
"@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ=="],
"@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw=="],
"@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g=="],
"@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA=="],
"@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g=="],
"@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg=="],
"@sentry/core": ["@sentry/core@10.36.0", "", {}, "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ=="],
"@sentry/solid": ["@sentry/solid@10.36.0", "", { "dependencies": { "@sentry/browser": "10.36.0", "@sentry/core": "10.36.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-AaDqz3JGBrQCm2YVqODVyJHwg7LRTNSJig9mjfProFyvkC7eUXQ/HBJrrhAD1Dct9ufmDH3G+f3/Ut9LgpItSg=="],
"@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="],
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
@@ -2725,7 +2804,7 @@
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3035,7 +3114,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="],
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3243,7 +3322,7 @@
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
@@ -3737,7 +3816,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
@@ -4141,7 +4220,7 @@
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
@@ -4161,7 +4240,7 @@
"pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
@@ -4191,7 +4270,7 @@
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
@@ -4951,7 +5030,7 @@
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="],
"unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="],
@@ -5059,7 +5138,9 @@
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"webpack-sources": ["webpack-sources@3.4.0", "", {}, "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
@@ -5591,13 +5672,17 @@
"@opencode-ai/desktop-electron/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -5613,6 +5698,16 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -5667,6 +5762,8 @@
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="],
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
@@ -5851,8 +5948,6 @@
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -5951,6 +6046,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -5959,7 +6058,7 @@
"ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
@@ -5971,6 +6070,8 @@
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
@@ -6063,12 +6164,16 @@
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
@@ -6557,6 +6662,8 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/llm/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -6567,6 +6674,16 @@
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
"@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@@ -6589,6 +6706,8 @@
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
@@ -6711,14 +6830,36 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -6747,6 +6888,8 @@
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
@@ -6971,6 +7114,12 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
@@ -7053,8 +7202,12 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
"pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
@@ -7067,6 +7220,8 @@
"tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -7121,6 +7276,8 @@
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
@@ -7147,6 +7304,8 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-h2T/LnUnISZZDn9ZQkZ/A59P+6+QdfOlrgl4RXK/vgM=",
"aarch64-linux": "sha256-+DRohG1ZEB/2LtZU90GWoqJkeyu/sW8A8oKT3f/TtQ0=",
"aarch64-darwin": "sha256-k4nsk/WduuxY8HgjRuqzGT9EjEo7V/2mAzBTYee0fZ0=",
"x86_64-darwin": "sha256-3dSvfN2+5lXwOx57x8NSIWbEZ1fp6+1T6bJpAuUNPyk="
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
}
}
-1
View File
@@ -55,7 +55,6 @@ stdenvNoCC.mkDerivation {
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--filter './packages/shared' \
--frozen-lockfile \
--ignore-scripts \
--no-progress
+5 -3
View File
@@ -34,8 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -53,7 +53,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.57",
"effect": "4.0.0-beta.59",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -77,6 +77,8 @@
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.30",
"version": "1.14.33",
"description": "",
"type": "module",
"exports": {
@@ -27,6 +27,7 @@
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -40,6 +41,7 @@
},
"dependencies": {
"@kobalte/core": "catalog:",
"@sentry/solid": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/core": "workspace:*",
+14 -6
View File
@@ -1,4 +1,5 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
@@ -148,12 +149,19 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
>
<LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
<ErrorBoundary
fallback={(error) => {
Sentry.captureException(error)
return <ErrorPage error={error} />
}}
>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
</LanguageProvider>
@@ -329,6 +329,7 @@ export const SettingsGeneral: Component = () => {
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value })
}}
variant="secondary"
+3
View File
@@ -204,6 +204,9 @@ function createGlobalSync() {
},
translate: language.t,
getSdk: sdkFor,
global: {
provider: globalStore.provider,
},
})
async function loadSessions(directory: string) {
@@ -260,9 +260,6 @@ export async function bootstrapDirectory(input: {
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
if (seededProject) input.setStore("project", seededProject)
if (seededPath) input.setStore("path", seededPath)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", reconcile(input.global.config, { merge: false }))
}
@@ -23,6 +23,7 @@ describe("createChildStoreManager", () => {
onDispose() {},
translate: (key) => key,
getSdk: () => null!,
global: { provider: null! },
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -1,7 +1,7 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -27,6 +27,9 @@ export function createChildStoreManager(input: {
onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
getSdk: (directory: string) => OpencodeClient
global: {
provider: ProviderListResponse
}
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>()
@@ -187,9 +190,15 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return providerQuery.isLoading
return !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
return input.global.provider
return providerQuery.data ?? EMPTY
},
provider: { all: [], connected: [], default: {} },
config: {},
get path() {
if (pathQuery.isLoading || !pathQuery.data)
@@ -207,13 +216,13 @@ export function createChildStoreManager(input: {
permission: {},
question: {},
get mcp_ready() {
return mcpQuery.isLoading
return !mcpQuery.isLoading
},
get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
get lsp_ready() {
return lspQuery.isLoading
return !lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
+1 -1
View File
@@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.model.variant ?? null,
variant: msg.model?.variant ?? null,
})
},
},
+20
View File
@@ -1,5 +1,6 @@
// @refresh reload
import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -125,6 +126,25 @@ const platform: Platform = {
setDefaultServer: writeDefaultServerUrl,
}
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`,
initialScope: {
tags: {
platform: "web",
},
},
integrations: (integrations) => {
return integrations.filter(
(i) =>
i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"),
)
},
})
}
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
render(
+4
View File
@@ -2,6 +2,10 @@ interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
readonly VITE_SENTRY_RELEASE?: string
}
interface ImportMeta {
+2
View File
@@ -402,6 +402,8 @@ export const dict = {
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
"error.page.details.label": "تفاصيل الخطأ",
"error.page.action.restart": "إعادة تشغيل",
"error.page.action.report": "الإبلاغ عن الخطأ",
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
"error.page.action.checking": "جارٍ التحقق...",
"error.page.action.checkUpdates": "التحقق من وجود تحديثات",
"error.page.action.updateTo": "تحديث إلى {{version}}",
+2
View File
@@ -403,6 +403,8 @@ export const dict = {
"error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.details.label": "Detalhes do Erro",
"error.page.action.restart": "Reiniciar",
"error.page.action.report": "Reportar erro",
"error.page.action.reported": "Erro reportado",
"error.page.action.checking": "Verificando...",
"error.page.action.checkUpdates": "Verificar atualizações",
"error.page.action.updateTo": "Atualizar para {{version}}",
+2
View File
@@ -449,6 +449,8 @@ export const dict = {
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
"error.page.details.label": "Detalji greške",
"error.page.action.restart": "Restartuj",
"error.page.action.report": "Prijavi grešku",
"error.page.action.reported": "Greška prijavljena",
"error.page.action.checking": "Provjera...",
"error.page.action.checkUpdates": "Provjeri ažuriranja",
"error.page.action.updateTo": "Ažuriraj na {{version}}",
+2
View File
@@ -446,6 +446,8 @@ export const dict = {
"error.page.description": "Der opstod en fejl under indlæsning af applikationen.",
"error.page.details.label": "Fejldetaljer",
"error.page.action.restart": "Genstart",
"error.page.action.report": "Rapportér fejl",
"error.page.action.reported": "Fejl rapporteret",
"error.page.action.checking": "Tjekker...",
"error.page.action.checkUpdates": "Tjek for opdateringer",
"error.page.action.updateTo": "Opdater til {{version}}",
+2
View File
@@ -410,6 +410,8 @@ export const dict = {
"error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.",
"error.page.details.label": "Fehlerdetails",
"error.page.action.restart": "Neustart",
"error.page.action.report": "Fehler melden",
"error.page.action.reported": "Fehler gemeldet",
"error.page.action.checking": "Prüfen...",
"error.page.action.checkUpdates": "Nach Updates suchen",
"error.page.action.updateTo": "Auf {{version}} aktualisieren",
+2
View File
@@ -465,6 +465,8 @@ export const dict = {
"error.page.description": "An error occurred while loading the application.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
"error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}",
+2
View File
@@ -449,6 +449,8 @@ export const dict = {
"error.page.description": "Ocurrió un error al cargar la aplicación.",
"error.page.details.label": "Detalles del error",
"error.page.action.restart": "Reiniciar",
"error.page.action.report": "Informar error",
"error.page.action.reported": "Error informado",
"error.page.action.checking": "Comprobando...",
"error.page.action.checkUpdates": "Buscar actualizaciones",
"error.page.action.updateTo": "Actualizar a {{version}}",
+2
View File
@@ -406,6 +406,8 @@ export const dict = {
"error.page.description": "Une erreur s'est produite lors du chargement de l'application.",
"error.page.details.label": "Détails de l'erreur",
"error.page.action.restart": "Redémarrer",
"error.page.action.report": "Signaler l'erreur",
"error.page.action.reported": "Erreur signalée",
"error.page.action.checking": "Vérification...",
"error.page.action.checkUpdates": "Vérifier les mises à jour",
"error.page.action.updateTo": "Mettre à jour vers {{version}}",
+2
View File
@@ -402,6 +402,8 @@ export const dict = {
"error.page.description": "アプリケーションの読み込み中にエラーが発生しました。",
"error.page.details.label": "エラー詳細",
"error.page.action.restart": "再起動",
"error.page.action.report": "エラーを報告",
"error.page.action.reported": "エラーを報告しました",
"error.page.action.checking": "確認中...",
"error.page.action.checkUpdates": "アップデートを確認",
"error.page.action.updateTo": "{{version}}にアップデート",
+2
View File
@@ -401,6 +401,8 @@ export const dict = {
"error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.",
"error.page.details.label": "오류 세부 정보",
"error.page.action.restart": "다시 시작",
"error.page.action.report": "오류 신고",
"error.page.action.reported": "오류가 신고됨",
"error.page.action.checking": "확인 중...",
"error.page.action.checkUpdates": "업데이트 확인",
"error.page.action.updateTo": "{{version}} 버전으로 업데이트",
+2
View File
@@ -450,6 +450,8 @@ export const dict = {
"error.page.description": "Det oppstod en feil under lasting av applikasjonen.",
"error.page.details.label": "Feildetaljer",
"error.page.action.restart": "Start på nytt",
"error.page.action.report": "Rapporter feil",
"error.page.action.reported": "Feil rapportert",
"error.page.action.checking": "Sjekker...",
"error.page.action.checkUpdates": "Se etter oppdateringer",
"error.page.action.updateTo": "Oppdater til {{version}}",
+2
View File
@@ -403,6 +403,8 @@ export const dict = {
"error.page.description": "Wystąpił błąd podczas ładowania aplikacji.",
"error.page.details.label": "Szczegóły błędu",
"error.page.action.restart": "Restartuj",
"error.page.action.report": "Zgłoś błąd",
"error.page.action.reported": "Błąd zgłoszony",
"error.page.action.checking": "Sprawdzanie...",
"error.page.action.checkUpdates": "Sprawdź aktualizacje",
"error.page.action.updateTo": "Zaktualizuj do {{version}}",
+2
View File
@@ -448,6 +448,8 @@ export const dict = {
"error.page.description": "Произошла ошибка при загрузке приложения.",
"error.page.details.label": "Детали ошибки",
"error.page.action.restart": "Перезапустить",
"error.page.action.report": "Сообщить об ошибке",
"error.page.action.reported": "Об ошибке сообщено",
"error.page.action.checking": "Проверка...",
"error.page.action.checkUpdates": "Проверить обновления",
"error.page.action.updateTo": "Обновить до {{version}}",
+2
View File
@@ -447,6 +447,8 @@ export const dict = {
"error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน",
"error.page.details.label": "รายละเอียดข้อผิดพลาด",
"error.page.action.restart": "รีสตาร์ท",
"error.page.action.report": "รายงานข้อผิดพลาด",
"error.page.action.reported": "รายงานข้อผิดพลาดแล้ว",
"error.page.action.checking": "กำลังตรวจสอบ...",
"error.page.action.checkUpdates": "ตรวจสอบการอัปเดต",
"error.page.action.updateTo": "อัปเดตเป็น {{version}}",
+2
View File
@@ -452,6 +452,8 @@ export const dict = {
"error.page.description": "Uygulama yüklenirken bir hata oluştu.",
"error.page.details.label": "Hata Detayları",
"error.page.action.restart": "Yeniden Başlat",
"error.page.action.report": "Hatayı Bildir",
"error.page.action.reported": "Hata Bildirildi",
"error.page.action.checking": "Kontrol ediliyor...",
"error.page.action.checkUpdates": "Güncellemeleri kontrol et",
"error.page.action.updateTo": "{{version}} sürümüne güncelle",
+2
View File
@@ -452,6 +452,8 @@ export const dict = {
"error.page.description": "加载应用程序时发生错误。",
"error.page.details.label": "错误详情",
"error.page.action.restart": "重启",
"error.page.action.report": "上报错误",
"error.page.action.reported": "错误已上报",
"error.page.action.checking": "检查中...",
"error.page.action.checkUpdates": "检查更新",
"error.page.action.updateTo": "更新到 {{version}}",
+2
View File
@@ -445,6 +445,8 @@ export const dict = {
"error.page.description": "載入應用程式時發生錯誤。",
"error.page.details.label": "錯誤詳情",
"error.page.action.restart": "重新啟動",
"error.page.action.report": "回報錯誤",
"error.page.action.reported": "已回報錯誤",
"error.page.action.checking": "檢查中...",
"error.page.action.checkUpdates": "檢查更新",
"error.page.action.updateTo": "更新到 {{version}}",
+20 -2
View File
@@ -1,7 +1,8 @@
import { TextField } from "@opencode-ai/ui/text-field"
import * as Sentry from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js"
import { Component, createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
@@ -270,10 +271,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
label={language.t("error.page.details.label")}
hideLabel
/>
<div class="flex items-center gap-3">
<div class="flex flex-row items-center justify-center gap-3 flex-wrap max-w-64">
<Button size="large" onClick={platform.restart}>
{language.t("error.page.action.restart")}
</Button>
<Show when={Sentry.isEnabled}>
{(_) => {
const [reported, setReported] = createSignal(false)
return (
<Button
size="large"
disabled={reported()}
onClick={() => {
Sentry.captureException(props.error)
setReported(true)
}}
>
{language.t(reported() ? "error.page.action.reported" : "error.page.action.report")}
</Button>
)
}}
</Show>
<Show when={platform.checkUpdate}>
<Show
when={store.version}
+20 -2
View File
@@ -1,8 +1,26 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "vite"
import desktopPlugin from "./vite"
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
? sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
telemetry: false,
release: {
name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE,
},
sourcemaps: {
assets: "./dist/**",
filesToDeleteAfterUpload: "./dist/**/*.map",
},
})
: false
export default defineConfig({
plugins: [desktopPlugin] as any,
plugins: [desktopPlugin, sentry] as any,
server: {
host: "0.0.0.0",
allowedHosts: true,
@@ -10,6 +28,6 @@ export default defineConfig({
},
build: {
target: "esnext",
// sourcemap: true,
sourcemap: true,
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.30",
"version": "1.14.33",
"type": "module",
"license": "MIT",
"scripts": {
@@ -141,7 +141,10 @@ export async function handler(
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({ provider: providerInfo.id })
logger.metric({
provider: providerInfo.id,
"provider.model": providerInfo.model,
})
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
@@ -149,12 +152,23 @@ export async function handler(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...providerInfo.payloadModifier,
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
...(() => {
const replacer = (obj: Record<string, any>): Record<string, any> =>
Object.fromEntries(
Object.entries(obj).flatMap(([k, v]) => {
if (Array.isArray(v)) return [[k, v]]
if (typeof v === "object") return [[k, replacer(v)]]
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : []
}
return [[k, v]]
}),
)
return replacer(providerInfo.payloadModifier ?? {})
})(),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
@@ -514,7 +528,6 @@ export async function handler(
reqModel,
providerModel: modelProvider.model,
adjustCacheUsage: providerProps.adjustCacheUsage,
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
workspaceID: authInfo?.workspaceID,
}
if (format === "anthropic") return anthropicHelper(opts)
@@ -23,7 +23,7 @@ type Usage = {
}
}
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
@@ -34,7 +34,6 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
return {
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
}
},
createBinaryStreamDecoder: () => undefined,
@@ -18,10 +18,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),
modifyBody: (body: Record<string, any>) => body,
createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => {
@@ -37,7 +37,6 @@ export type ProviderHelper = (input: {
reqModel: string
providerModel: string
adjustCacheUsage?: boolean
safetyIdentifier?: string
workspaceID?: string
}) => {
format: ZenData.Format
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.30",
"version": "1.14.33",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -40,7 +40,6 @@ export namespace ZenData {
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
safetyIdentifier: z.boolean().optional(),
}),
),
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.30",
"version": "1.14.33",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.30",
"version": "1.14.33",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.30",
"version": "1.14.33",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+22 -2
View File
@@ -1,4 +1,5 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -10,6 +11,10 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
// Channels that default to the new effect-httpapi server backend. The legacy
// hono backend remains the default for stable (`prod`/`latest`) installs.
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -47,7 +52,7 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE,
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
@@ -67,6 +72,13 @@ export const Flag = {
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
// Opt-in to the LLM-native stream path in `session/llm.ts`. Today this
// routes a narrow slice of sessions (text-only, Anthropic, with explicit
// `nativeMessages` populated by the caller) through the
// `@opencode-ai/llm` core stack instead of `streamText` from the AI SDK.
// Everything else falls through to the existing path. The flag will go
// away once parity is proven across all six protocols.
OPENCODE_EXPERIMENTAL_LLM_NATIVE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LLM_NATIVE"),
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
@@ -81,8 +93,16 @@ export const Flag = {
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
// Defaults to true on dev/beta/local channels so internal users exercise the
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
// on the legacy hono backend until the rollout is complete. An explicit env
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
// stable users and an escape hatch for dev/beta users.
OPENCODE_EXPERIMENTAL_HTTPAPI:
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+26 -11
View File
@@ -4,12 +4,14 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
const app = "opencode"
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app)
const paths = {
get home() {
@@ -21,6 +23,7 @@ const paths = {
cache,
config,
state,
tmp,
}
export const Path = paths
@@ -31,6 +34,7 @@ await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
])
@@ -43,23 +47,34 @@ export interface Interface {
readonly cache: string
readonly config: string
readonly state: string
readonly tmp: string
readonly bin: string
readonly log: string
}
export function make(input: Partial<Interface> = {}): Interface {
return {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
state: Path.state,
tmp: Path.tmp,
bin: Path.bin,
log: Path.log,
...input,
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Path.config,
state: Path.state,
bin: Path.bin,
log: Path.log,
})
}),
Effect.sync(() => Service.of(make())),
)
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,
Effect.sync(() => Service.of(make(input))),
)
export * as Global from "./global"
+6 -2
View File
@@ -120,13 +120,17 @@ export const layer = Layer.effect(
}
})()
if (yield* afs.existsSafe(dir)) {
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (Option.isSome(result.entrypoint)) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
+2
View File
@@ -1,3 +1,5 @@
export * as Log from "./log"
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
@@ -18,20 +18,17 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
const msg: Msg = JSON.parse(process.argv[2]!)
const msg: Msg = JSON.parse(process.argv[2])
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Global } from "@opencode-ai/core/global"
describe("global paths", () => {
test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})
+35
View File
@@ -1,7 +1,12 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
@@ -15,6 +20,14 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
}),
)
const npmLayer = (cache: string) =>
Npm.layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
Layer.provide(NodeFileSystem.layer),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -29,6 +42,28 @@ describe("Npm.sanitize", () => {
})
})
describe("Npm.add", () => {
test("reifies when package cache directory exists without the package installed", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
await writePackage(path.join(tmp.path, "fixture-provider"), {
name: "fixture-provider",
main: "index.js",
})
await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(Option.isSome(entry.entrypoint)).toBe(true)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
+9 -12
View File
@@ -93,18 +93,15 @@ async function waitForFile(file: string, timeout = 3_000) {
// Test layer
// ---------------------------------------------------------------------------
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+1 -8
View File
@@ -2,13 +2,6 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
"noUncheckedIndexedAccess": false
}
}
@@ -1,3 +1,4 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
@@ -12,6 +13,23 @@ const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
? sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
telemetry: false,
release: {
name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE,
},
sourcemaps: {
assets: "./out/renderer/**",
filesToDeleteAfterUpload: "./out/renderer/**/*.map",
},
})
: false
export default defineConfig({
main: {
define: {
@@ -61,13 +79,14 @@ export default defineConfig({
},
},
renderer: {
plugins: [appPlugin],
plugins: [appPlugin, sentry],
publicDir: "../../../app/public",
root: "src/renderer",
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
build: {
sourcemap: true,
rollupOptions: {
input: {
main: "src/renderer/index.html",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.30",
"version": "1.14.33",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -38,6 +38,8 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
@@ -14,6 +14,7 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
@@ -29,6 +30,25 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(t("error.dev.rootNotFound"))
}
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop-electron@${pkg.version}`,
initialScope: {
tags: {
platform: "desktop-electron",
},
},
integrations: (integrations) => {
return integrations.filter(
(i) =>
i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"),
)
},
})
}
void initI18n()
const deepLinkEvent = "opencode:deep-link"
@@ -26,13 +26,20 @@ const applyZoom = (next: number) => {
window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
let newZoom = webviewZoom()
if (event.key === "-") newZoom -= 0.2
if (event.key === "=" || event.key === "+") newZoom += 0.2
if (event.key === "0") newZoom = 1
applyZoom(clamp(newZoom))
if (event.key === "-") {
event.preventDefault()
applyZoom(clamp(webviewZoom() - 0.2))
return
}
if (event.key === "=" || event.key === "+") {
event.preventDefault()
applyZoom(clamp(webviewZoom() + 0.2))
return
}
if (event.key === "0") {
event.preventDefault()
applyZoom(1)
}
})
export { webviewZoom }
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.30",
"version": "1.14.33",
"type": "module",
"license": "MIT",
"scripts": {
@@ -15,6 +15,7 @@
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
@@ -35,6 +36,7 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@sentry/vite-plugin": "catalog:",
"@tauri-apps/cli": "^2",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
+9
View File
@@ -0,0 +1,9 @@
interface ImportMetaEnv {
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
readonly VITE_SENTRY_RELEASE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+1
View File
@@ -14,6 +14,7 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
+3 -3
View File
@@ -15,9 +15,9 @@ export default defineConfig({
// Improves production stack traces
keepNames: true,
},
// build: {
// sourcemap: true,
// },
build: {
sourcemap: true,
},
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.30",
"version": "1.14.33",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.30"
version = "1.14.33"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.30",
"version": "1.14.33",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:"
}
}
+105
View File
@@ -0,0 +1,105 @@
import { Context, Effect, FileSystem, Layer, Path, PlatformError, Ref } from "effect"
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
export interface Entry {
readonly name: string
readonly path: string
}
export interface Interface {
readonly path: (name: string) => string
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
export const layer = (options: { readonly directory?: string } = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem
const paths = yield* Path.Path
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
const pathFor = (name: string) => cassettePath(name, directory)
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fileSystem
.readDirectory(directory)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = paths.join(directory, entry)
return fileSystem.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
})
return nested.flat()
})
const read = Effect.fn("Cassette.read")(function* (name: string) {
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
})
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* fileSystem.makeDirectory(paths.dirname(pathFor(name)), { recursive: true })
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
const append = Effect.fn("Cassette.append")(function* (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) {
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
)
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
const findings = cassetteSecretFindings(cassette)
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
const list = Effect.fn("Cassette.list")(function* () {
return (yield* walk(directory))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
name: paths.relative(directory, file).replace(/\.json$/, ""),
path: file,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
})
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}),
).pipe(Layer.provide(Path.layer))
export const defaultLayer = layer()
export * as Cassette from "./cassette"
+95
View File
@@ -0,0 +1,95 @@
import { Option } from "effect"
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
import { decodeJson } from "./matching"
import { REDACTED, redactUrl, secretFindings } from "./redaction"
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
if (!text) return String(value)
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (
expected &&
received &&
typeof expected === "object" &&
typeof received === "object" &&
!Array.isArray(expected) &&
!Array.isArray(received)
) {
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) =>
valueDiffs(
(expected as Record<string, unknown>)[key],
(received as Record<string, unknown>)[key],
`${base}.${key}`,
limit,
),
)
.slice(0, limit)
}
if (Array.isArray(expected) && Array.isArray(received)) {
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
}
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
const lines = []
if (expected.method !== received.method) {
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
}
if (expected.url !== received.url) {
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
const interactions = httpInteractions(cassette)
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
+210
View File
@@ -0,0 +1,210 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option, Ref } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import {
httpInteractions,
type Cassette,
type CassetteMetadata,
type HttpInteraction,
type ResponseSnapshot,
} from "./schema"
import * as CassetteService from "./cassette"
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
export type RecordReplayMode = "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
}
readonly requestHeaders?: ReadonlyArray<string>
readonly responseHeaders?: ReadonlyArray<string>
readonly redactBody?: (body: unknown) => unknown
readonly dispatch?: "match" | "sequential"
readonly match?: RequestMatcher
}
const responseHeaders = (
response: HttpClientResponse.HttpClientResponse,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> | undefined,
) => {
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
return merged
}
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
const isBinaryContentType = (contentType: string | undefined) => {
if (!contentType) return false
const lower = contentType.toLowerCase()
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
}
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
isBinaryContentType(contentType)
? response.arrayBuffer.pipe(
Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })),
)
: response.text.pipe(Effect.map((body) => ({ body })))
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
}),
})
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
}),
})
const unsafeCassette = (
request: HttpClientRequest.HttpClientRequest,
name: string,
findings: ReadonlyArray<SecretFinding>,
) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
}),
})
export const recordingLayer = (
name: string,
options: Omit<RecordReplayOptions, "directory"> = {},
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient
const cassetteService = yield* CassetteService.Service
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
const match = options.match ?? defaultMatcher
const mode = options.mode ?? "replay"
const sequential = options.dispatch === "sequential"
const replay = yield* Ref.make<Cassette | undefined>(undefined)
const cursor = yield* Ref.make(0)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
const raw = yield* Effect.promise(() => web.text())
const body = options.redactBody
? Option.match(decodeJson(raw), {
onNone: () => raw,
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
})
: raw
return {
method: web.method,
url: redactUrl(web.url, options.redact?.query),
headers: redactHeaders(
Object.fromEntries(web.headers.entries()),
requestHeadersAllow,
options.redact?.headers,
),
body,
}
})
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
Effect.gen(function* () {
const interactions = httpInteractions(cassette)
if (sequential) {
const index = yield* Ref.get(cursor)
const interaction = interactions[index]
if (!interaction)
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
if (!match(incoming, interaction.request)) {
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
}
yield* Ref.update(cursor, (n) => n + 1)
return { interaction, detail: "" }
}
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
})
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
yield* Ref.set(replay, cassette)
return cassette
})
return HttpClient.make((request) => {
if (mode === "passthrough") return upstream.execute(request)
if (mode === "record") {
return Effect.gen(function* () {
const currentRequest = yield* snapshotRequest(request)
const response = yield* upstream.execute(request)
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
const captured = yield* captureResponseBody(response, headers["content-type"])
const interaction: HttpInteraction = {
transport: "http",
request: currentRequest,
response: { status: response.status, headers, ...captured },
}
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
const findings = result.findings
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
)
})
}
return Effect.gen(function* () {
const cassette = yield* loadReplay(request)
const incoming = yield* snapshotRequest(request)
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
if (!interaction) return yield* fixtureMismatch(request, name, detail)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
)
})
})
}),
)
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(CassetteService.layer({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)
+10
View File
@@ -0,0 +1,10 @@
export * from "./schema"
export * from "./redaction"
export * from "./matching"
export * from "./diff"
export * from "./storage"
export * from "./websocket"
export * from "./effect"
export * as Cassette from "./cassette"
export * as HttpRecorder from "."
+36
View File
@@ -0,0 +1,36 @@
import { Option, Schema } from "effect"
import type { RequestSnapshot } from "./schema"
const JsonValue = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value)
export const canonicalizeJson = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(canonicalizeJson)
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value)
.toSorted()
.map((key) => [key, canonicalizeJson(value[key])]),
)
}
return value
}
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
JSON.stringify({
method: snapshot.method,
url: snapshot.url,
headers: canonicalizeJson(snapshot.headers),
body: Option.match(decodeJson(snapshot.body), {
onNone: () => snapshot.body,
onSome: canonicalizeJson,
}),
})
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
+110
View File
@@ -0,0 +1,110 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
"authorization",
"cookie",
"proxy-authorization",
"set-cookie",
"x-api-key",
"x-amz-security-token",
"x-goog-api-key",
]
const DEFAULT_REDACT_QUERY = [
"access_token",
"api-key",
"api_key",
"apikey",
"code",
"key",
"signature",
"sig",
"token",
"x-amz-credential",
"x-amz-security-token",
"x-amz-signature",
]
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
]
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
const envSecrets = () =>
Object.entries(process.env).flatMap(([name, value]) => {
if (!value) return []
if (!ENV_SECRET_NAMES.test(name)) return []
if (value.length < 12) return []
if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
return [{ name, value }]
})
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
if (typeof value === "string") return [{ path: base, value }]
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
}
return []
}
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
export const redactUrl = (raw: string, query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY) => {
if (!URL.canParse(raw)) return raw
const url = new URL(raw)
if (url.username) url.username = REDACTED
if (url.password) url.password = REDACTED
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
for (const key of [...url.searchParams.keys()]) {
if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
}
return url.toString()
}
export const redactHeaders = (
headers: Record<string, string>,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
) => {
const allowed = new Set(allow.map((name) => name.toLowerCase()))
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
return Object.fromEntries(
Object.entries(headers)
.map(([name, value]) => [name.toLowerCase(), value] as const)
.filter(([name]) => allowed.has(name))
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
.toSorted(([a], [b]) => a.localeCompare(b)),
)
}
export type SecretFinding = {
readonly path: string
readonly reason: string
}
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
stringEntries(value).flatMap((entry) => [
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
path: entry.path,
reason: item.label,
})),
...envSecrets()
.filter((item) => entry.value.includes(item.value))
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
+67
View File
@@ -0,0 +1,67 @@
import { Schema } from "effect"
export const RequestSnapshotSchema = Schema.Struct({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
})
export type RequestSnapshot = Schema.Schema.Type<typeof RequestSnapshotSchema>
export const ResponseSnapshotSchema = Schema.Struct({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
})
export type ResponseSnapshot = Schema.Schema.Type<typeof ResponseSnapshotSchema>
export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
export type CassetteMetadata = Schema.Schema.Type<typeof CassetteMetadataSchema>
export const HttpInteractionSchema = Schema.Struct({
transport: Schema.tag("http"),
request: RequestSnapshotSchema,
response: ResponseSnapshotSchema,
})
export type HttpInteraction = Schema.Schema.Type<typeof HttpInteractionSchema>
export const WebSocketFrameSchema = Schema.Union([
Schema.Struct({ kind: Schema.tag("text"), body: Schema.String }),
Schema.Struct({ kind: Schema.tag("binary"), body: Schema.String, bodyEncoding: Schema.Literal("base64") }),
])
export type WebSocketFrame = Schema.Schema.Type<typeof WebSocketFrameSchema>
export const WebSocketInteractionSchema = Schema.Struct({
transport: Schema.tag("websocket"),
open: Schema.Struct({
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}),
client: Schema.Array(WebSocketFrameSchema),
server: Schema.Array(WebSocketFrameSchema),
})
export type WebSocketInteraction = Schema.Schema.Type<typeof WebSocketInteractionSchema>
export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
Schema.toTaggedUnion("transport"),
)
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1),
metadata: Schema.optional(CassetteMetadataSchema),
interactions: Schema.Array(InteractionSchema),
})
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
export const encodeCassette = Schema.encodeSync(CassetteSchema)
+34
View File
@@ -0,0 +1,34 @@
import { Option } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
name,
recordedAt: new Date().toISOString(),
...(metadata ?? {}),
})
export const cassetteFor = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: metadataFor(name, metadata),
interactions,
})
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
const file = cassettePath(name, options.directory)
if (!fs.existsSync(file)) return false
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
}
+203
View File
@@ -0,0 +1,203 @@
import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
export interface WebSocketRequest {
readonly url: string
readonly headers: Headers.Headers
}
export interface WebSocketConnection<E> {
readonly sendText: (message: string) => Effect.Effect<void, E>
readonly messages: Stream.Stream<string | Uint8Array, E>
readonly close: Effect.Effect<void>
}
export interface WebSocketExecutor<E> {
readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
}
export interface WebSocketRecordReplayOptions<E> {
readonly name: string
readonly mode?: "record" | "replay" | "passthrough"
readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E>
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
}
readonly requestHeaders?: ReadonlyArray<string>
readonly compareClientMessagesAsJson?: boolean
}
const headersRecord = (headers: Headers.Headers) =>
Object.fromEntries(
Object.entries(headers as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.toSorted(([a], [b]) => a.localeCompare(b)),
)
const openSnapshot = (
request: WebSocketRequest,
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
) => ({
url: redactUrl(request.url, options.redact?.query),
headers: redactHeaders(
headersRecord(request.headers),
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
options.redact?.headers,
),
})
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
const frameText = (frame: WebSocketFrame) => {
if (frame.kind === "text") return frame.body
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
}
const frameMessage = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
typeof message === "string"
? textFrame(message)
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
new Error(
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
)
const mismatch = (message: string, actual: unknown, expected: unknown) =>
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw mismatch(message, actual, expected)
})
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
if (!expected)
return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
})
const expectedText = frameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
}
export const makeWebSocketExecutor = <E>(
options: WebSocketRecordReplayOptions<E>,
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () {
const mode = options.mode ?? "replay"
if (mode === "passthrough") return options.live
if (mode === "record") {
return {
open: (request) =>
Effect.gen(function* () {
const client: WebSocketFrame[] = []
const server: WebSocketFrame[] = []
const connection = yield* options.live.open(request)
const closed = yield* Ref.make(false)
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
const result = yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request, options), client, server },
options.metadata,
)
.pipe(Effect.orDie)
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
})
return {
sendText: (message: string) =>
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
messages: connection.messages.pipe(
Stream.map((message) => {
server.push(receivedFrame(message))
return message
}),
),
close: closeOnce,
}
}),
}
}
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
undefined,
)
const cursor = yield* Ref.make(0)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const input = yield* Ref.get(replay)
if (!input) return
yield* assertEqual(
`Unused recorded WebSocket interactions in ${options.name}`,
yield* Ref.get(cursor),
input.interactions.length,
)
}),
)
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const input = {
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
}
yield* Ref.set(replay, input)
return input
})
return {
open: (request) => {
return Effect.gen(function* () {
const input = yield* loadReplay()
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
const interaction = input.interactions[index]
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
const messageIndex = yield* Ref.make(0)
return {
sendText: (message: string) =>
Effect.gen(function* () {
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
yield* compareClientMessage(
message,
interaction.client[current],
current,
options.compareClientMessagesAsJson === true,
)
}),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
close: Effect.gen(function* () {
yield* assertEqual(
`WebSocket client frame count for interaction ${index + 1}`,
yield* Ref.get(messageIndex),
interaction.client.length,
)
}),
}
})
},
}
})
@@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":1}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"first\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":2}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"second\"}"
}
}
]
}
@@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"pending\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"complete\"}"
}
}
]
}
@@ -0,0 +1,312 @@
import { NodeFileSystem } from "@effect/platform-node"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Scope, Stream } from "effect"
import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/diff"
const post = (url: string, body: object) =>
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const request = HttpClientRequest.post(url, {
headers: { "content-type": "application/json" },
body: HttpBody.text(JSON.stringify(body), "application/json"),
})
const response = yield* http.execute(request)
return yield* response.text
})
const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step"))))
const runWith = <A, E>(
name: string,
options: HttpRecorder.RecordReplayOptions,
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options))))
const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Service | Scope.Scope>) =>
Effect.runPromise(
Effect.scoped(
effect.pipe(
Effect.provide(
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
),
Effect.provide(NodeFileSystem.layer),
),
),
)
const failureText = (exit: Exit.Exit<unknown, unknown>) => {
if (Exit.isSuccess(exit)) return ""
return Cause.prettyErrors(exit.cause).join("\n")
}
describe("http-recorder", () => {
test("redacts sensitive URL query parameters", () => {
expect(
HttpRecorder.redactUrl(
"https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
),
).toBe(
"https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
)
})
test("redacts URL credentials", () => {
expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe(
"https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
)
})
test("redacts sensitive headers when allow-listed", () => {
expect(
HttpRecorder.redactHeaders(
{
authorization: "Bearer secret-token",
"content-type": "application/json",
"x-custom-token": "custom-secret",
"x-api-key": "secret-key",
"x-goog-api-key": "secret-google-key",
},
["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
["x-custom-token"],
),
).toEqual({
authorization: "[REDACTED]",
"content-type": "application/json",
"x-api-key": "[REDACTED]",
"x-custom-token": "[REDACTED]",
"x-goog-api-key": "[REDACTED]",
})
})
test("redacts error requests without retaining headers, params, or body", () => {
const request = HttpClientRequest.post("https://example.test/path", {
headers: { authorization: "Bearer super-secret" },
body: HttpBody.text("super-secret-body", "text/plain"),
}).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
expect(redactedErrorRequest(request).toJSON()).toMatchObject({
url: "https://example.test/path",
urlParams: { params: [] },
headers: {},
body: { _tag: "Empty" },
})
})
test("detects secret-looking values without returning the secret", () => {
expect(
HttpRecorder.cassetteSecretFindings({
version: 1,
interactions: [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/path?key=sk-123456789012345678901234",
headers: {},
body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
},
response: {
status: 200,
headers: {},
body: "Bearer abcdefghijklmnopqrstuvwxyz",
},
},
],
}),
).toEqual([
{ path: "interactions[0].request.url", reason: "API key" },
{ path: "interactions[0].request.body", reason: "Google API key" },
{ path: "interactions[0].response.body", reason: "bearer token" },
])
})
test("detects secret-looking values inside metadata", () => {
expect(
HttpRecorder.cassetteSecretFindings({
version: 1,
metadata: { token: "sk-123456789012345678901234" },
interactions: [],
}),
).toEqual([{ path: "metadata.token", reason: "API key" }])
})
test("formats websocket cassettes with shared metadata", () => {
const cassette = HttpRecorder.cassetteFor(
"websocket/basic",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
{ provider: "openai" },
)
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
HttpRecorder.cassetteFor(
"websocket/replay",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}),
)
})
test("records websocket interactions into the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/record",
mode: "record",
metadata: { provider: "test" },
cassette,
live: {
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]),
close: Effect.void,
}),
},
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject({
metadata: { name: "websocket/record", provider: "test" },
interactions: [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
})
}),
)
})
test("default matcher dispatches multi-interaction cassettes by request shape", async () => {
await run(
Effect.gen(function* () {
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
}),
)
})
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
await runWith(
"record-replay/retry",
{ dispatch: "sequential" },
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
}),
)
})
test("default matcher returns the first match for identical requests", async () => {
await runWith(
"record-replay/retry",
{},
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
}),
)
})
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
yield* post("https://example.test/echo", { step: 2 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
test("sequential dispatch still validates each recorded request", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain("$.step expected 2, received 3")
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
}),
)
})
test("mismatch diagnostics show closest redacted request differences", async () => {
await run(
Effect.gen(function* () {
const exit = yield* Effect.exit(
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
)
const message = failureText(exit)
expect(message).toContain("closest interaction: #1")
expect(message).toContain("url:")
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
expect(message).toContain("body:")
expect(message).toContain("$.step expected 1, received 3")
expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
expect(message).not.toContain("sk-123456789012345678901234")
}),
)
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
+294
View File
@@ -0,0 +1,294 @@
# LLM Package Guide
## Effect
- Prefer `HttpClient.HttpClient` / `HttpClientResponse.HttpClientResponse` over web `fetch` / `Response` at package boundaries.
- Use `Stream.Stream` for streaming data flow. Avoid ad hoc async generators or manual web reader loops unless an Effect `Stream` API cannot model the behavior.
- Use Effect Schema codecs for JSON encode/decode (`Schema.fromJsonString(...)`) instead of direct `JSON.parse` / `JSON.stringify` in implementation code.
- In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`.
- Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void.
## Tests
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks.
## Architecture
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
### Request Flow
The intended callsite is:
```ts
const request = LLM.request({
model: OpenAI.model("gpt-4o-mini", { apiKey }),
system: "You are concise.",
prompt: "Say hello.",
})
const response = yield* LLMClient.generate(request)
```
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` selects a registered route by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
### Routes
A route is the registered, runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — path construction. The host always lives on `model.baseURL`; the endpoint just supplies the path. `Endpoint.path("/chat/completions")` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Routes read `model.apiKey` at request time via `Auth.bearer` (the default; sets `Authorization: Bearer <apiKey>`) or `Auth.apiKeyHeader(name)` for providers that use a custom header (Anthropic `x-api-key`, Gemini `x-goog-api-key`). Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
Compose them via `Route.make(...)`:
```ts
export const route = Route.make({
id: "openai-chat",
provider: "openai",
protocol: OpenAIChat.protocol,
transport: HttpTransport.httpJson({
endpoint: Endpoint.path("/chat/completions"),
auth: Auth.bearer(),
framing: Framing.sse,
encodeBody,
}),
defaults: {
baseURL: "https://api.openai.com/v1",
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
},
})
```
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.json(...)` constructs a transport whose `prepare` builds a WebSocket URL and message and whose `frames` yields decoded text from the socket. Same protocol, different transport.
### URL Construction
`model.baseURL` is required; `Endpoint` only carries the path. Each protocol's `Route.make` includes a canonical URL in `defaults.baseURL` (e.g. `https://api.openai.com/v1`); provider helpers can override by passing `baseURL` in their input. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) set `baseURL: string` (required) on their input type so TypeScript catches a missing host at the call site.
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper computes `baseURL` at model construction time. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
### Provider Definitions
Provider-facing APIs are defined with `Provider.make(...)` from `src/provider.ts`:
```ts
export const provider = Provider.make({
id: ProviderID.make("openai"),
model: responses,
apis: { responses, chat },
})
export const model = provider.model
export const apis = provider.apis
```
Keep provider definitions small and explicit:
- Use only `id`, `model`, and optional `apis` in `Provider.make(...)`.
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
- Use `model` for the default API path and `apis` for named provider-native alternatives such as OpenAI `responses` versus `chat`.
- Do not add author-facing `kind`, `version`, or `routes` fields.
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
Built-in providers are namespace modules from `src/providers/index.ts`, so aliases like `OpenAI.model(...)`, `OpenAI.responses(...)`, and `OpenAI.apis.chat(...)` are fine. External provider packages should default-export the `Provider.make(...)` result and may add named aliases if useful.
### Folder layout
```
packages/llm/src/
schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Capabilities, Limits, ModelRef
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
errors.ts error reasons, LLMError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
index.ts @opencode-ai/llm/route advanced barrel
client.ts Route.make + LLMClient.prepare/stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
auth.ts Auth type + Auth.bearer / Auth.apiKeyHeader / Auth.passthrough
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse
transport/ transport implementations
index.ts Transport type + HttpTransport / WebSocketTransport namespaces
http.ts HttpTransport.httpJson — POST + framing
websocket.ts WebSocketTransport.json + WebSocketExecutor service
protocols/
shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
openai-responses.ts
anthropic-messages.ts
gemini.ts
bedrock-converse.ts
bedrock-event-stream.ts framing for AWS event-stream binary frames
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
providers/
openai-compatible.ts generic compatible helper + family model helpers
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed tool() helper
tool-runtime.ts implementation helpers for LLMClient tool execution
```
The dependency arrow points down: `providers/*.ts` files import `protocols`, `endpoint`, `auth`, and `framing`; protocols do not import provider metadata. Lower-level modules know nothing about specific providers.
### Shared protocol helpers
`ProviderShared` exports a small toolkit used inside protocol implementations to keep them focused on provider-native shapes:
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
### Tools
Tool loops are represented in common messages and events:
```ts
const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })
const result = LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } })
const followUp = LLM.request({
model,
messages: [LLM.user("Weather?"), LLM.assistant([call]), result],
})
```
Routes lower these into provider-native assistant tool-call messages and tool-result messages. Streaming providers should emit `tool-input-delta` events while arguments arrive, then a final `tool-call` event with parsed input.
### Tool runtime
`LLM.stream({ request, tools })` executes model-requested tools with full type safety. Plain `LLM.stream(request)` only streams the model; if `request.tools` contains schemas, tool calls are returned for the caller to handle. Use `toolExecution: "none"` to pass executable tool definitions as schemas without invoking handlers. Add `stopWhen` to opt into follow-up model rounds after tool results.
```ts
const get_weather = tool({
description: "Get current weather for a city",
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
execute: ({ city }) =>
Effect.gen(function* () {
// city: string — typed from parameters Schema
const data = yield* WeatherApi.fetch(city)
return { temperature: data.temp, condition: data.cond }
// return type checked against success Schema
}),
})
const events = yield* LLM.stream({
request,
tools: { get_weather, get_time, ... },
stopWhen: LLM.stepCountIs(10),
}).pipe(Stream.runCollect)
```
The runtime:
- Adds tool definitions (derived from each tool's `parameters` Schema via `Schema.toJsonSchemaDocument`) onto `request.tools`.
- Streams the model.
- On `tool-call`: looks up the named tool, decodes input against `parameters` Schema, dispatches to the typed `execute`, encodes the result against `success` Schema, emits `tool-result`.
- Emits local `tool-result` events in the same step by default.
- Loops only when `stopWhen` is provided and the step finishes with `tool-calls`, appending the assistant + tool messages.
Handler dependencies (services, permissions, plugin hooks, abort handling) are closed over by the consumer at tool-construction time. The runtime's only environment requirement is `RequestExecutor.Service`. Build the tools record inside an `Effect.gen` once and reuse it across many runs.
Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `tool-error` event, then a `tool-result` of `type: "error"`, so the model can self-correct on the next step. Anything that is not a `ToolFailure` is treated as a defect and fails the stream. Three recoverable error paths produce `tool-error` events:
- The model called an unknown tool name.
- Input failed the `parameters` Schema.
- The handler returned a `ToolFailure`.
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
- The runtime detects `providerExecuted` on `tool-call` and **skips client dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
- Both events are appended to the assistant message in `assistantContent` so the next round's history carries the call + result for context. Anthropic encodes them back as `server_tool_use` + `web_search_tool_result` (or `code_execution_tool_result` / `web_fetch_tool_result`) blocks; OpenAI Responses callers typically use `previous_response_id` instead of resending hosted-tool items.
Add provider-defined tools to `request.tools` (no runtime entry needed). The matching route must know how to lower the tool definition into the provider-native shape; right now Anthropic accepts `web_search` / `code_execution` / `web_fetch` and OpenAI Responses accepts the hosted tool names listed above.
## Protocol File Style
Protocol files should look self-similar. Provider quirks belong behind named helpers so a new route can be reviewed by comparing the same sections across files.
### Section order
Use this order for every protocol module:
1. Public model input
2. Request body schema
3. Streaming event schema
4. Parser state
5. Request body construction (`fromRequest`)
6. Stream parsing (`step` and per-event handlers)
7. Protocol and route
8. Model helper
### Rules
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `request-finish` (or `provider-error`) when a `terminal` event arrives. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `request-finish` event for a completed response. Use `stream.terminal` to signal the run is over and have `step` emit the final event.
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance.
- Keep tests in the same conceptual order as the protocol: basic prepare, tools prepare, unsupported lowering, text/usage parsing, tool streaming, finish reasons, provider errors.
### Review checklist
- Can the file be skimmed side-by-side with `openai-chat.ts` without hunting for equivalent sections?
- Are provider quirks named, isolated, and covered by focused tests?
- Does request body construction validate unsupported common content at the protocol boundary?
- Does stream parsing emit stable common events without leaking provider event order to callers?
- Does `toolChoice: "none"` behavior read as intentional?
## Recording Tests
Recorded tests use one cassette file per scenario. A cassette holds an ordered array of `{ request, response }` interactions, so multi-step flows (tool loops, retries, polling) record into a single file. Use `recordedTests({ prefix, requires })` and let the helper derive cassette names from test names:
```ts
const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] })
recorded.effect("streams text", () =>
Effect.gen(function* () {
// test body
}),
)
```
Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable.
Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file:
- `RECORDED_PROVIDER=openai` matches tests tagged with `provider:openai`; comma-separated values are allowed.
- `RECORDED_PREFIX=openai-chat` matches cassette groups by `recordedTests({ prefix })`; comma-separated values are allowed.
- `RECORDED_TAGS=tool` requires all listed tags to be present, e.g. `RECORDED_TAGS=provider:togetherai,tool`.
- `RECORDED_TEST="streams text"` matches by test name, kebab-case test id, or cassette path.
Filters apply in replay and record mode. Combine them with `RECORD=true` when refreshing only one provider or scenario.
**Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text.
**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed.
+203
View File
@@ -0,0 +1,203 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, Provider, ProviderID, Tool, type ProviderModelOptions } from "@opencode-ai/llm"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/llm/route"
import { OpenAI } from "@opencode-ai/llm/providers"
/**
* A runnable walkthrough of the LLM package use-site API.
*
* Run from `packages/llm` with an OpenAI key in the environment:
*
* OPENAI_API_KEY=... bun example/tutorial.ts
*
* The file is intentionally written as a normal TypeScript program. You can
* hover imports and local values to see how the public API is typed.
*/
const apiKey = Config.redacted("OPENAI_API_KEY")
// 1. Pick a model. The provider helper records provider identity, protocol
// choice, capabilities, deployment options, authentication, and defaults.
const model = OpenAI.model("gpt-4o-mini", {
apiKey,
generation: { maxTokens: 160 },
providerOptions: {
openai: { store: false },
},
})
// 2. Build a provider-neutral request. This is useful when reusing one request
// across generate and stream examples.
//
// Options can live on both the model and the request:
//
// - `generation`: common controls such as max tokens, temperature, topP/topK,
// penalties, seed, and stop sequences.
// - `providerOptions`: namespaced provider-native behavior. For example,
// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking
// config, or OpenRouter routing/reasoning.
// - `http`: last-resort serializable overlays for final request body, headers,
// and query params. Prefer typed `providerOptions` when a field is stable.
//
// Model options are defaults. Request options override them for this call.
const request = LLM.request({
model,
system: "You are concise and practical.",
prompt: "Tell me a joke",
generation: { maxTokens: 80, temperature: 0.7 },
providerOptions: {
openai: { promptCacheKey: "tutorial-joke" },
},
})
// `http` is intentionally not needed for normal calls. This shows the shape for
// newly released provider fields before they deserve a typed provider option.
const rawOverlayExample = LLM.request({
model,
prompt: "Show the final HTTP overlay shape.",
http: {
body: { metadata: { example: "tutorial" } },
headers: { "x-opencode-tutorial": "1" },
query: { debug: "1" },
},
})
// 3. `generate` sends the request and collects the event stream into one
// response object. `response.text` is the collected text output.
const generateOnce = Effect.gen(function* () {
const response = yield* LLM.generate(request)
console.log("\n== generate ==")
console.log("generated text:", response.text)
console.log("usage", Formatter.formatJson(response.usage, { space: 2 }))
})
// 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want
// incremental text, reasoning, tool input, usage, or finish events.
const streamText = LLM.stream(request).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
if (event.type === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
}),
),
Stream.runDrain,
)
// 5. Tools are typed with Effect Schema. Passing tools to `LLMClient.stream`
// adds their definitions to the request and dispatches matching tool calls.
// Add `stopWhen` to opt into follow-up model rounds after tool results.
const tools = {
get_weather: Tool.make({
description: "Get current weather for a city.",
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.String }),
execute: (input) => Effect.succeed({ forecast: `${input.city}: sunny, 72F` }),
}),
}
const streamWithTools = LLM.stream({
request: LLM.request({
model,
prompt: "Use get_weather for San Francisco, then answer in one sentence.",
generation: { maxTokens: 80, temperature: 0 },
}),
tools,
stopWhen: LLM.stepCountIs(3),
}).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "tool-call") console.log("tool call", event.name, event.input)
if (event.type === "tool-result") console.log("tool result", event.name, event.result)
if (event.type === "text-delta") process.stdout.write(event.text)
}),
),
Stream.runDrain,
)
// -----------------------------------------------------------------------------
// Part 2: provider composition with a fake provider
// -----------------------------------------------------------------------------
// A protocol is the provider-native API shape: common request -> body, response
// frames -> common events. This fake one turns text prompts into a JSON body
// and treats every SSE frame as output text.
const FakeBody = Schema.Struct({
model: Schema.String,
input: Schema.String,
})
type FakeBody = Schema.Schema.Type<typeof FakeBody>
const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
// Protocol ids are open strings, so external packages can define their own
// protocols without changing this package.
id: "fake-echo",
body: {
schema: FakeBody,
from: (request) =>
Effect.succeed({
model: request.model.id,
input: request.messages
.flatMap((message) => message.content)
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n"),
}),
},
stream: {
event: Schema.String,
initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
onHalt: () => [{ type: "request-finish", reason: "stop" }],
},
})
// An route is the runnable binding for that protocol. It adds the deployment
// axes that the protocol deliberately does not know: URL, auth, and framing.
const FakeAdapter = Route.make({
id: "fake-echo",
protocol: FakeProtocol,
endpoint: Endpoint.path("/v1/echo"),
auth: Auth.passthrough,
framing: Framing.sse,
})
// A provider module exports a Provider definition. The default `model` helper
// sets provider identity, protocol id, and the route id resolved by the registry.
const fakeEchoModel = Route.model(FakeAdapter, { provider: "fake-echo", baseURL: "https://fake.local" })
const FakeEcho = Provider.make({
id: ProviderID.make("fake-echo"),
model: (id: string, options: ProviderModelOptions = {}) => fakeEchoModel({ id, ...options }),
})
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
// body conversion, validation, endpoint, auth, and HTTP construction without
// sending anything over the network.
const inspectFakeProvider = Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: FakeEcho.model("tiny-echo"),
prompt: "Show me the provider pipeline.",
}),
)
console.log("\n== fake provider prepare ==")
console.log("route:", prepared.route)
console.log("body:", Formatter.formatJson(prepared.body, { space: 2 }))
})
// Provide the LLM runtime and the HTTP request executor once. Keep one path
// enabled at a time so the tutorial can demonstrate generate, prepare, stream,
// or tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.defaultLayer
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const program = Effect.gen(function* () {
// yield* generateOnce
// yield* inspectFakeProvider
// yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body))))
// yield* streamText
yield* streamWithTools
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
Effect.runPromise(program)
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.25",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"setup:recording-env": "bun run script/setup-recording-env.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./route": "./src/route/index.ts",
"./provider": "./src/provider.ts",
"./providers": "./src/providers/index.ts",
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
"./providers/anthropic": "./src/providers/anthropic.ts",
"./providers/azure": "./src/providers/azure.ts",
"./providers/github-copilot": "./src/providers/github-copilot.ts",
"./providers/google": "./src/providers/google.ts",
"./providers/openai": "./src/providers/openai.ts",
"./providers/openai-compatible": "./src/providers/openai-compatible.ts",
"./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts",
"./providers/openrouter": "./src/providers/openrouter.ts",
"./providers/xai": "./src/providers/xai.ts",
"./protocols": "./src/protocols/index.ts",
"./protocols/anthropic-messages": "./src/protocols/anthropic-messages.ts",
"./protocols/bedrock-converse": "./src/protocols/bedrock-converse.ts",
"./protocols/gemini": "./src/protocols/gemini.ts",
"./protocols/openai-chat": "./src/protocols/openai-chat.ts",
"./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts",
"./protocols/openai-responses": "./src/protocols/openai-responses.ts"
},
"devDependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
"effect": "catalog:"
}
}
@@ -0,0 +1,250 @@
import * as fs from "node:fs/promises"
import * as path from "node:path"
const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings")
const MODELS_DEV_URL = "https://models.dev/api.json"
type JsonRecord = Record<string, unknown>
type Pricing = {
readonly input?: number
readonly output?: number
readonly cache_read?: number
readonly cache_write?: number
readonly reasoning?: number
}
type Usage = {
readonly inputTokens: number
readonly outputTokens: number
readonly cacheReadTokens: number
readonly cacheWriteTokens: number
readonly reasoningTokens: number
readonly reportedCost: number
}
type Row = Usage & {
readonly cassette: string
readonly provider: string
readonly model: string
readonly estimatedCost: number
readonly pricingSource: string
}
const isRecord = (value: unknown): value is JsonRecord =>
value !== null && typeof value === "object" && !Array.isArray(value)
const asNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : 0)
const asString = (value: unknown) => (typeof value === "string" ? value : undefined)
const readJson = async (file: string) => JSON.parse(await Bun.file(file).text()) as unknown
const walk = async (dir: string): Promise<ReadonlyArray<string>> =>
(await fs.readdir(dir, { withFileTypes: true }))
.flatMap((entry) => {
const file = path.join(dir, entry.name)
return entry.isDirectory() ? [] : [file]
})
.concat(
...(await Promise.all(
(await fs.readdir(dir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => walk(path.join(dir, entry.name))),
)),
)
const providerFromUrl = (url: string) => {
if (url.includes("api.openai.com")) return "openai"
if (url.includes("api.anthropic.com")) return "anthropic"
if (url.includes("generativelanguage.googleapis.com")) return "google"
if (url.includes("bedrock")) return "amazon-bedrock"
if (url.includes("openrouter.ai")) return "openrouter"
if (url.includes("api.x.ai")) return "xai"
if (url.includes("api.groq.com")) return "groq"
if (url.includes("api.deepseek.com")) return "deepseek"
if (url.includes("api.together.xyz")) return "togetherai"
return "unknown"
}
const providerAliases: Record<string, ReadonlyArray<string>> = {
openai: ["openai"],
anthropic: ["anthropic"],
google: ["google"],
"amazon-bedrock": ["amazon-bedrock"],
openrouter: ["openrouter", "openai", "anthropic", "google"],
xai: ["xai"],
groq: ["groq"],
deepseek: ["deepseek"],
togetherai: ["togetherai"],
}
const modelAliases = (model: string) => [
model,
model.replace(/^models\//, ""),
model.replace(/-\d{8}$/, ""),
model.replace(/-\d{4}-\d{2}-\d{2}$/, ""),
model.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, ""),
model.replace(/^openai\//, ""),
model.replace(/^anthropic\//, ""),
model.replace(/^google\//, ""),
]
const pricingFor = (models: JsonRecord, provider: string, model: string) => {
for (const providerID of providerAliases[provider] ?? [provider]) {
const providerEntry = models[providerID]
if (!isRecord(providerEntry) || !isRecord(providerEntry.models)) continue
for (const modelID of modelAliases(model)) {
const modelEntry = providerEntry.models[modelID]
if (isRecord(modelEntry) && isRecord(modelEntry.cost))
return { pricing: modelEntry.cost as Pricing, source: `${providerID}/${modelID}` }
}
}
return { pricing: undefined, source: "missing" }
}
const estimateCost = (usage: Usage, pricing: Pricing | undefined) => {
if (!pricing) return 0
return (
(usage.inputTokens * (pricing.input ?? 0) +
usage.outputTokens * (pricing.output ?? 0) +
usage.cacheReadTokens * (pricing.cache_read ?? 0) +
usage.cacheWriteTokens * (pricing.cache_write ?? 0) +
usage.reasoningTokens * (pricing.reasoning ?? 0)) /
1_000_000
)
}
const emptyUsage = (): Usage => ({
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
reportedCost: 0,
})
const addUsage = (a: Usage, b: Usage): Usage => ({
inputTokens: a.inputTokens + b.inputTokens,
outputTokens: a.outputTokens + b.outputTokens,
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
reasoningTokens: a.reasoningTokens + b.reasoningTokens,
reportedCost: a.reportedCost + b.reportedCost,
})
const usageFromObject = (usage: unknown): Usage => {
if (!isRecord(usage)) return emptyUsage()
const promptDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {}
const completionDetails = isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : {}
const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : {}
const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : {}
const cacheWriteTokens = asNumber(promptDetails.cache_write_tokens) + asNumber(inputDetails.cache_write_tokens)
return {
inputTokens: asNumber(usage.prompt_tokens) + asNumber(usage.input_tokens),
outputTokens: asNumber(usage.completion_tokens) + asNumber(usage.output_tokens),
cacheReadTokens: asNumber(promptDetails.cached_tokens) + asNumber(inputDetails.cached_tokens),
cacheWriteTokens,
reasoningTokens: asNumber(completionDetails.reasoning_tokens) + asNumber(outputDetails.reasoning_tokens),
reportedCost: asNumber(usage.cost),
}
}
const jsonPayloads = (body: string) =>
body
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trim())
.filter((line) => line !== "" && line !== "[DONE]")
.flatMap((line) => {
try {
return [JSON.parse(line) as unknown]
} catch {
return []
}
})
const usageFromResponseBody = (body: string) =>
jsonPayloads(body).reduce<Usage>((usage, payload) => {
if (!isRecord(payload)) return usage
return addUsage(
usage,
addUsage(
usageFromObject(payload.usage),
usageFromObject(isRecord(payload.response) ? payload.response.usage : undefined),
),
)
}, emptyUsage())
const modelFromRequest = (request: unknown) => {
if (!isRecord(request)) return "unknown"
const requestBody = asString(request.body)
if (!requestBody) return "unknown"
try {
const body = JSON.parse(requestBody) as unknown
if (!isRecord(body)) return "unknown"
return asString(body.model) ?? "unknown"
} catch {
return "unknown"
}
}
const rowFor = (models: JsonRecord, file: string, cassette: unknown): Row | undefined => {
if (!isRecord(cassette) || !Array.isArray(cassette.interactions)) return undefined
const first = cassette.interactions.find(isRecord)
if (!first || !isRecord(first.request)) return undefined
const provider = providerFromUrl(asString(first.request.url) ?? "")
const model = modelFromRequest(first.request)
const usage = cassette.interactions.filter(isRecord).reduce<Usage>((total, interaction) => {
if (!isRecord(interaction.response)) return total
const responseBody = asString(interaction.response.body)
if (!responseBody) return total
return addUsage(total, usageFromResponseBody(responseBody))
}, emptyUsage())
const priced = pricingFor(models, provider, model)
return {
cassette: path.relative(RECORDINGS_DIR, file),
provider,
model,
...usage,
estimatedCost: estimateCost(usage, priced.pricing),
pricingSource: priced.source,
}
}
const money = (value: number) => (value === 0 ? "$0.000000" : `$${value.toFixed(6)}`)
const tokens = (value: number) => value.toLocaleString("en-US")
const models = (await (await fetch(MODELS_DEV_URL)).json()) as JsonRecord
const rows = (
await Promise.all(
(await walk(RECORDINGS_DIR))
.filter((file) => file.endsWith(".json"))
.map(async (file) => rowFor(models, file, await readJson(file))),
)
).filter((row): row is Row => row !== undefined)
const totals = rows.reduce(
(total, row) => ({
...addUsage(total, row),
estimatedCost: total.estimatedCost + row.estimatedCost,
}),
{ ...emptyUsage(), estimatedCost: 0 },
)
console.log("# Recording Cost Report")
console.log("")
console.log(`Pricing: ${MODELS_DEV_URL}`)
console.log(`Cassettes: ${rows.length}`)
console.log(`Reported cost: ${money(totals.reportedCost)}`)
console.log(`Estimated cost: ${money(totals.estimatedCost)}`)
console.log("")
console.log("| Provider | Model | Input | Output | Reasoning | Reported | Estimated | Pricing | Cassette |")
console.log("|---|---:|---:|---:|---:|---:|---:|---|---|")
for (const row of rows.toSorted((a, b) => b.reportedCost + b.estimatedCost - (a.reportedCost + a.estimatedCost))) {
if (row.inputTokens + row.outputTokens + row.reasoningTokens + row.reportedCost + row.estimatedCost === 0) continue
console.log(
`| ${row.provider} | ${row.model} | ${tokens(row.inputTokens)} | ${tokens(row.outputTokens)} | ${tokens(row.reasoningTokens)} | ${money(row.reportedCost)} | ${money(row.estimatedCost)} | ${row.pricingSource} | ${row.cassette} |`,
)
}
+483
View File
@@ -0,0 +1,483 @@
#!/usr/bin/env bun
import { NodeFileSystem } from "@effect/platform-node"
import * as path from "node:path"
import * as prompts from "@clack/prompts"
import { AwsV4Signer } from "aws4fetch"
import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
import * as ProviderShared from "../src/protocols/shared"
type Provider = {
readonly id: string
readonly label: string
readonly tier: "core" | "canary" | "compatible" | "optional"
readonly note: string
readonly vars: ReadonlyArray<{
readonly name: string
readonly label?: string
readonly optional?: boolean
}>
}
const PROVIDERS: ReadonlyArray<Provider> = [
{
id: "openai",
label: "OpenAI",
tier: "core",
note: "Native OpenAI Chat / Responses recorded tests",
vars: [{ name: "OPENAI_API_KEY" }],
},
{
id: "anthropic",
label: "Anthropic",
tier: "core",
note: "Native Anthropic Messages recorded tests",
vars: [{ name: "ANTHROPIC_API_KEY" }],
},
{
id: "google",
label: "Google Gemini",
tier: "core",
note: "Native Gemini recorded tests",
vars: [{ name: "GOOGLE_GENERATIVE_AI_API_KEY" }],
},
{
id: "bedrock",
label: "Amazon Bedrock",
tier: "core",
note: "Native Bedrock Converse recorded tests",
vars: [
{ name: "AWS_ACCESS_KEY_ID" },
{ name: "AWS_SECRET_ACCESS_KEY" },
{ name: "AWS_SESSION_TOKEN", optional: true },
{ name: "BEDROCK_RECORDING_REGION", optional: true },
{ name: "BEDROCK_MODEL_ID", optional: true },
],
},
{
id: "groq",
label: "Groq",
tier: "canary",
note: "Fast OpenAI-compatible canary for text/tool streaming",
vars: [{ name: "GROQ_API_KEY" }],
},
{
id: "openrouter",
label: "OpenRouter",
tier: "canary",
note: "Router canary for OpenAI-compatible text/tool streaming",
vars: [{ name: "OPENROUTER_API_KEY" }],
},
{
id: "xai",
label: "xAI",
tier: "canary",
note: "OpenAI-compatible xAI chat endpoint",
vars: [{ name: "XAI_API_KEY" }],
},
{
id: "deepseek",
label: "DeepSeek",
tier: "compatible",
note: "Existing OpenAI-compatible recorded tests",
vars: [{ name: "DEEPSEEK_API_KEY" }],
},
{
id: "togetherai",
label: "TogetherAI",
tier: "compatible",
note: "Existing OpenAI-compatible text/tool recorded tests",
vars: [{ name: "TOGETHER_AI_API_KEY" }],
},
{
id: "mistral",
label: "Mistral",
tier: "optional",
note: "OpenAI-compatible bridge; native reasoning parity is follow-up work",
vars: [{ name: "MISTRAL_API_KEY" }],
},
{
id: "perplexity",
label: "Perplexity",
tier: "optional",
note: "OpenAI-compatible bridge; citations/search metadata are follow-up work",
vars: [{ name: "PERPLEXITY_API_KEY" }],
},
{
id: "venice",
label: "Venice",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "VENICE_API_KEY" }],
},
{
id: "cerebras",
label: "Cerebras",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "CEREBRAS_API_KEY" }],
},
{
id: "deepinfra",
label: "DeepInfra",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "DEEPINFRA_API_KEY" }],
},
{
id: "fireworks",
label: "Fireworks",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "FIREWORKS_API_KEY" }],
},
{
id: "baseten",
label: "Baseten",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "BASETEN_API_KEY" }],
},
]
const args = process.argv.slice(2)
const hasFlag = (name: string) => args.includes(name)
const option = (name: string) => {
const index = args.indexOf(name)
if (index === -1) return undefined
return args[index + 1]
}
const envPath = path.resolve(process.cwd(), option("--env") ?? ".env.local")
const checkOnly = hasFlag("--check")
const providerOption = option("--providers")
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
type Env = Record<string, string>
const envNames = Array.from(new Set(PROVIDERS.flatMap((provider) => provider.vars.map((item) => item.name))))
const providersForOption = (value: string | undefined) => {
if (!value || value === "recommended")
return PROVIDERS.filter((provider) => provider.tier === "core" || provider.tier === "canary")
if (value === "recorded") return PROVIDERS.filter((provider) => provider.tier !== "optional")
if (value === "all") return PROVIDERS
const ids = new Set(
value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
)
return PROVIDERS.filter((provider) => ids.has(provider.id))
}
const chooseProviders = async () => {
if (providerOption) return providersForOption(providerOption)
return providersForOption("recommended")
}
const catchMissingFile = (error: PlatformError.PlatformError) => {
if (error.reason._tag === "NotFound") return Effect.succeed("")
return Effect.fail(error)
}
const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () {
const fileSystem = yield* FileSystem.FileSystem
return yield* fileSystem.readFileString(envPath).pipe(Effect.catch(catchMissingFile))
})
const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) =>
Config.string(name)
.parse(provider)
.pipe(
Effect.match({
onFailure: () => undefined,
onSuccess: (value) => value,
}),
)
const parseEnv = Effect.fn("RecordingEnv.parseEnv")(function* (contents: string) {
const provider = ConfigProvider.fromDotEnvContents(contents)
return Object.fromEntries(
(yield* Effect.forEach(envNames, (name) =>
readConfigString(provider, name).pipe(Effect.map((value) => [name, value] as const)),
)).filter((entry): entry is readonly [string, string] => entry[1] !== undefined),
)
})
const quote = (value: string) => JSON.stringify(value)
const status = (name: string, fileEnv: Env) => {
if (fileEnv[name]) return "file"
if (process.env[name]) return "shell"
return "missing"
}
const statusLine = (provider: Provider, fileEnv: Env) =>
[
`${provider.label} (${provider.tier})`,
provider.note,
...provider.vars.map((item) => {
const value = status(item.name, fileEnv)
const suffix = item.optional ? " optional" : ""
return ` ${value === "missing" ? "missing" : "set"} ${item.name}${suffix}${value === "shell" ? " (shell only)" : ""}`
}),
].join("\n")
const printStatus = (providers: ReadonlyArray<Provider>, fileEnv: Env) => {
prompts.note(providers.map((provider) => statusLine(provider, fileEnv)).join("\n\n"), `Recording env: ${envPath}`)
}
const exitIfCancel = <A>(value: A | symbol): A => {
if (!prompts.isCancel(value)) return value as A
prompts.cancel("Cancelled")
process.exit(130)
}
const upsertEnv = (contents: string, values: Env) => {
const names = Object.keys(values)
const seen = new Set<string>()
const lines = contents.split(/\r?\n/).map((line) => {
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)
if (!match || !names.includes(match[1])) return line
seen.add(match[1])
return `${match[1]}=${quote(values[match[1]])}`
})
const missing = names.filter((name) => !seen.has(name))
if (missing.length === 0) return lines.join("\n").replace(/\n*$/, "\n")
const prefix = lines.join("\n").trimEnd()
const block = [
"",
"# Added by bun run setup:recording-env",
...missing.map((name) => `${name}=${quote(values[name])}`),
].join("\n")
return `${prefix}${block}\n`
}
const providerRequiredStatus = (provider: Provider, fileEnv: Env) => {
const required = provider.vars.filter((item) => !item.optional)
if (required.some((item) => status(item.name, fileEnv) === "missing")) return "missing"
if (required.some((item) => status(item.name, fileEnv) === "shell")) return "set in shell"
return "already added"
}
const processEnv = (): Env =>
Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined))
const envWithValues = (fileEnv: Env, values: Env): Env => ({
...processEnv(),
...fileEnv,
...values,
})
const responseError = Effect.fn("RecordingEnv.responseError")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
if (response.status >= 200 && response.status < 300) return undefined
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
})
const executeRequest = Effect.fn("RecordingEnv.executeRequest")(function* (
request: HttpClientRequest.HttpClientRequest,
) {
const http = yield* HttpClient.HttpClient
return yield* http.execute(request).pipe(Effect.flatMap(responseError))
})
const validateBearer = (url: string, token: Redacted.Redacted<string>, headers: Record<string, string> = {}) =>
HttpClientRequest.get(url).pipe(
HttpClientRequest.setHeaders({ ...headers, authorization: `Bearer ${Redacted.value(token)}` }),
executeRequest,
)
const validateChat = (input: {
readonly url: string
readonly token: Redacted.Redacted<string>
readonly model: string
}) =>
ProviderShared.jsonPost({
url: input.url,
headers: { authorization: `Bearer ${Redacted.value(input.token)}` },
body: ProviderShared.encodeJson({
model: input.model,
messages: [{ role: "user", content: "Reply with exactly: ok" }],
max_tokens: 3,
temperature: 0,
}),
}).pipe(executeRequest)
const validateProvider = Effect.fn("RecordingEnv.validateProvider")(function* (provider: Provider, env: Env) {
const check = Effect.gen(function* () {
if (provider.id === "openai")
return yield* validateBearer("https://api.openai.com/v1/models", Redacted.make(env.OPENAI_API_KEY))
if (provider.id === "anthropic") {
return yield* HttpClientRequest.get("https://api.anthropic.com/v1/models").pipe(
HttpClientRequest.setHeaders({
"anthropic-version": "2023-06-01",
"x-api-key": Redacted.value(Redacted.make(env.ANTHROPIC_API_KEY)),
}),
executeRequest,
)
}
if (provider.id === "google") {
return yield* HttpClientRequest.get(
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(env.GOOGLE_GENERATIVE_AI_API_KEY)}`,
).pipe(executeRequest)
}
if (provider.id === "bedrock") {
const request = yield* Effect.promise(() =>
new AwsV4Signer({
url: `https://bedrock.${env.BEDROCK_RECORDING_REGION || "us-east-1"}.amazonaws.com/foundation-models`,
method: "GET",
service: "bedrock",
region: env.BEDROCK_RECORDING_REGION || "us-east-1",
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
sessionToken: env.AWS_SESSION_TOKEN || undefined,
}).sign(),
)
return yield* HttpClientRequest.get(request.url.toString()).pipe(
HttpClientRequest.setHeaders(Object.fromEntries(request.headers.entries())),
executeRequest,
)
}
if (provider.id === "groq")
return yield* validateBearer("https://api.groq.com/openai/v1/models", Redacted.make(env.GROQ_API_KEY))
if (provider.id === "openrouter") {
return yield* validateChat({
url: "https://openrouter.ai/api/v1/chat/completions",
token: Redacted.make(env.OPENROUTER_API_KEY),
model: "openai/gpt-4o-mini",
})
}
if (provider.id === "xai")
return yield* validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY))
if (provider.id === "deepseek")
return yield* validateBearer("https://api.deepseek.com/models", Redacted.make(env.DEEPSEEK_API_KEY))
if (provider.id === "togetherai")
return yield* validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY))
if (provider.id === "mistral")
return yield* validateBearer("https://api.mistral.ai/v1/models", Redacted.make(env.MISTRAL_API_KEY))
if (provider.id === "perplexity")
return yield* validateBearer("https://api.perplexity.ai/models", Redacted.make(env.PERPLEXITY_API_KEY))
if (provider.id === "venice")
return yield* validateBearer("https://api.venice.ai/api/v1/models", Redacted.make(env.VENICE_API_KEY))
if (provider.id === "cerebras")
return yield* validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY))
if (provider.id === "deepinfra")
return yield* validateBearer("https://api.deepinfra.com/v1/openai/models", Redacted.make(env.DEEPINFRA_API_KEY))
if (provider.id === "fireworks")
return yield* validateBearer("https://api.fireworks.ai/inference/v1/models", Redacted.make(env.FIREWORKS_API_KEY))
return "no lightweight validator"
})
return yield* check.pipe(
Effect.catch((error) => {
if (error instanceof Error) return Effect.succeed(error.message)
return Effect.succeed(String(error))
}),
)
})
const validateProviders = Effect.fn("RecordingEnv.validateProviders")(function* (
providers: ReadonlyArray<Provider>,
env: Env,
) {
const spinner = prompts.spinner()
spinner.start("Validating credentials")
const results = yield* Effect.forEach(
providers,
(provider) => validateProvider(provider, env).pipe(Effect.map((error) => ({ provider, error }))),
{ concurrency: 4 },
)
spinner.stop("Validation complete")
prompts.note(
results
.map(
(result) =>
`${result.error ? "failed" : "ok"} ${result.provider.label}${result.error ? ` - ${result.error}` : ""}`,
)
.join("\n"),
"Credential validation",
)
})
const writeEnvFile = Effect.fn("RecordingEnv.writeFile")(function* (contents: string) {
const fileSystem = yield* FileSystem.FileSystem
yield* fileSystem.makeDirectory(path.dirname(envPath), { recursive: true })
yield* fileSystem.writeFileString(envPath, contents, { mode: 0o600 })
})
const prompt = <A>(run: () => Promise<A | symbol>) => Effect.promise(run).pipe(Effect.map(exitIfCancel))
const main = Effect.fn("RecordingEnv.main")(function* () {
prompts.intro("LLM recording credentials")
const contents = yield* readEnvFile()
const fileEnv = yield* parseEnv(contents)
const providers = yield* Effect.promise(() => chooseProviders())
printStatus(providers, fileEnv)
if (checkOnly) {
prompts.outro("Check complete")
return
}
if (!interactive) {
prompts.outro("Run this command in a terminal to enter credentials")
return
}
const values: Env = {}
const configurableProviders = providers.filter((provider) => provider.vars.some((item) => !item.optional))
const selected = yield* prompt<ReadonlyArray<string>>(() =>
prompts.multiselect({
message: "Select provider credentials to add or override",
options: configurableProviders.map((provider) => ({
value: provider.id,
label: provider.label,
hint: `${providerRequiredStatus(provider, fileEnv)} - ${provider.vars
.filter((item) => !item.optional)
.map((item) => item.name)
.join(", ")}`,
})),
initialValues: configurableProviders
.filter((provider) => providerRequiredStatus(provider, fileEnv) === "missing")
.map((provider) => provider.id),
}),
)
const selectedProviders = configurableProviders.filter((provider) => selected.includes(provider.id))
for (const provider of selectedProviders) {
prompts.log.info(`${provider.label}: ${provider.note}`)
for (const item of provider.vars.filter((item) => !item.optional)) {
const value = yield* prompt<string>(() =>
prompts.password({
message: item.label ?? item.name,
validate: (input) =>
!input || input.length === 0 ? "Leave blank by pressing Esc/cancel, or paste a value" : undefined,
}),
)
if (value !== "") values[item.name] = value
}
}
if (Object.keys(values).length === 0) {
prompts.outro("No changes")
return
}
if (
interactive &&
(yield* prompt(() => prompts.confirm({ message: "Validate credentials before saving?", initialValue: true })))
) {
yield* validateProviders(selectedProviders, envWithValues(fileEnv, values))
}
yield* writeEnvFile(upsertEnv(contents, values))
prompts.log.success(
`Saved ${Object.keys(values).length} value${Object.keys(values).length === 1 ? "" : "s"} to ${envPath}`,
)
prompts.outro("Keep .env.local local. Store shared team credentials in a password manager or vault.")
})
await Effect.runPromise(main().pipe(Effect.provide(NodeFileSystem.layer), Effect.provide(FetchHttpClient.layer)))
+37
View File
@@ -0,0 +1,37 @@
export { LLMClient, modelCapabilities, modelLimits, modelRef } from "./route/client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export type {
RouteModelInput,
RouteRoutedModelInput,
Interface as LLMClientShape,
Service as LLMClientService,
ModelCapabilitiesInput,
ModelRefInput,
} from "./route/client"
export * from "./schema"
export { Tool, ToolFailure, toDefinitions, tool } from "./tool"
export type {
AnyExecutableTool,
AnyTool,
ExecutableTool,
ExecutableTools,
Tool as ToolShape,
ToolExecute,
Tools,
ToolSchema,
} from "./tool"
export type {
RunOptions as ToolRunOptions,
RuntimeState as ToolRuntimeState,
StopCondition as ToolStopCondition,
ToolExecution,
} from "./tool-runtime"
export * as LLM from "./llm"
export type { CapabilitiesInput } from "./llm"
export type {
Definition as ProviderDefinition,
ModelFactory as ProviderModelFactory,
ModelOptions as ProviderModelOptions,
} from "./provider"

Some files were not shown because too many files have changed in this diff Show More