mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-23 17:17:43 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353fb69c9b |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
"@opencode/core": patch
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
|
||||
@@ -53,6 +53,8 @@ runs:
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -64,9 +66,9 @@ runs:
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
|
||||
bun install --linker hoisted ${{ inputs.install-flags }}
|
||||
else
|
||||
bun install --frozen-lockfile ${{ inputs.install-flags }}
|
||||
bun install ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
name: deploy-files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-files-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: services/files
|
||||
run: bun typecheck
|
||||
|
||||
- name: Deploy
|
||||
working-directory: services/files
|
||||
run: bun run deploy --env ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -24,13 +24,13 @@ jobs:
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
working-directory: services/www
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
working-directory: services/www
|
||||
working-directory: packages/www
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "packages/*/package.json"
|
||||
- "services/*/package.json"
|
||||
- "flake.lock"
|
||||
- "nix/node_modules.nix"
|
||||
- "nix/scripts/**"
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -33,7 +33,7 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && !inputs.bump && !inputs.version && 'dev') || '' }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Deploy update service
|
||||
if: github.ref_name == 'v2'
|
||||
working-directory: services/update
|
||||
working-directory: packages/updates
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode; do
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli-macos
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -276,9 +276,10 @@ jobs:
|
||||
|
||||
sign-cli-windows:
|
||||
needs:
|
||||
- sign-cli-macos
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'v2' || github.ref_name == 'beta')
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -291,8 +292,15 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- 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 }}
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
@@ -307,9 +315,9 @@ jobs:
|
||||
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
files: |
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
|
||||
exclude-environment-credential: true
|
||||
exclude-workload-identity-credential: true
|
||||
exclude-managed-identity-credential: true
|
||||
@@ -325,9 +333,9 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe"
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
@@ -337,17 +345,40 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- name: Repack Windows CLI archives
|
||||
working-directory: packages/opencode/dist
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
|
||||
|
||||
- name: Upload signed Windows CLI release assets
|
||||
if: needs.version.outputs.release != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
gh release upload "v${{ needs.version.outputs.version }}" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
|
||||
--clobber `
|
||||
--repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
name: opencode-cli-signed-windows
|
||||
path: |
|
||||
packages/opencode/dist/opencode-windows-arm64
|
||||
packages/opencode/dist/opencode-windows-x64
|
||||
packages/opencode/dist/opencode-windows-x64-baseline
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- version
|
||||
- sign-cli-windows
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name != 'v2' || needs.version.outputs.release != '')
|
||||
- sign-cli-macos
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -639,6 +670,19 @@ jobs:
|
||||
git config --global user.name "opencode"
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
||||
|
||||
- name: Upload desktop release assets
|
||||
if: needs.version.outputs.release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
files=(/tmp/desktop/*.{exe,blockmap,dmg,zip,AppImage,deb,rpm} /tmp/desktop/*.app.tar.gz)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "No desktop release assets found"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "v${{ needs.version.outputs.version }}" "${files[@]}" --clobber --repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- run: ./script/publish.ts
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -650,6 +694,3 @@ jobs:
|
||||
LATEST_YML_DIR: /tmp/latest-yml
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
OPENCODE_DESKTOP_DIST: /tmp/desktop
|
||||
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
echo "app=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --output=json > affected.json
|
||||
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
|
||||
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
|
||||
|
||||
unit:
|
||||
@@ -112,12 +112,11 @@ jobs:
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
# The runners have four vCPUs, and each Bun test process performs its own concurrent work.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
GITHUB_ACTIONS=false bun turbo test --concurrency=3
|
||||
GITHUB_ACTIONS=false bun turbo test
|
||||
exit 0
|
||||
fi
|
||||
GITHUB_ACTIONS=false bun turbo test --affected --concurrency=3
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
@@ -133,10 +132,10 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
bun turbo verify:package --filter=@opencode/sdk
|
||||
bun turbo verify:package --filter=@opencode-ai/sdk
|
||||
exit 0
|
||||
fi
|
||||
bun turbo verify:package --affected --filter=@opencode/sdk
|
||||
bun turbo verify:package --affected --filter=@opencode-ai/sdk
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
@@ -174,7 +173,7 @@ jobs:
|
||||
|
||||
- name: Check generated documentation
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: services/www
|
||||
working-directory: packages/www
|
||||
run: bun run check:generated
|
||||
|
||||
e2e:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: check
|
||||
name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,8 +8,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: typecheck
|
||||
typecheck:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -18,5 +17,5 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run checks
|
||||
run: bun run check
|
||||
- name: Run typecheck
|
||||
run: bun typecheck
|
||||
@@ -32,7 +32,6 @@ target
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
UPCOMING_CHANGELOG.md
|
||||
RELEASE_REVIEW.md
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+1
-1
@@ -17,4 +17,4 @@ if (process.versions.bun !== expectedBunVersion) {
|
||||
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
|
||||
}
|
||||
'
|
||||
bun run check
|
||||
bun typecheck
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode/plugin"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
...options,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode/plugin"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
|
||||
+37
-14
@@ -1,22 +1,45 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "Reflect",
|
||||
"message": "Use typed property access or direct invocation. Suppress this rule only for genuine reflection."
|
||||
}
|
||||
]
|
||||
"typescript/no-base-to-string": "warn",
|
||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||
"require-yield": "off",
|
||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||
"no-unassigned-vars": "off",
|
||||
// SolidJS tracks reactive deps by reading properties inside createEffect
|
||||
"no-unused-expressions": "off",
|
||||
// Intentional control char matching (ANSI escapes, null byte sanitization)
|
||||
"no-control-regex": "off",
|
||||
// SST and plugin tools require triple-slash references
|
||||
"triple-slash-reference": "off",
|
||||
|
||||
// Suspicious category: suppress noisy rules
|
||||
// Effect's nested function* closures inherently shadow outer scope
|
||||
"no-shadow": "off",
|
||||
// Namespace-heavy codebase makes this too noisy
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/no-array-reverse": "off",
|
||||
// Not relevant — this isn't a DOM event handler codebase
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
// Bundler handles module resolution
|
||||
"unicorn/require-module-specifiers": "off",
|
||||
// postMessage target origin not relevant for this codebase
|
||||
"unicorn/require-post-message-target-origin": "off",
|
||||
// Side-effectful constructors are intentional in some places
|
||||
"no-new": "off",
|
||||
|
||||
// Type-aware: catch unhandled promises
|
||||
"typescript/no-floating-promises": "warn",
|
||||
// Warn when spreading non-plain objects (Headers, class instances, etc.)
|
||||
"typescript/no-misused-spread": "warn"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## V2 TUI Stories
|
||||
@@ -84,9 +84,9 @@ const { a, b } = obj
|
||||
### Imports
|
||||
|
||||
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
|
||||
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
|
||||
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
|
||||
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
|
||||
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode/core/project"`, then reference `Project.ID`.
|
||||
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
|
||||
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
|
||||
|
||||
### Variables
|
||||
@@ -170,10 +170,9 @@ const table = sqliteTable("session", {
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
|
||||
|
||||
## Checks
|
||||
## Type Checking
|
||||
|
||||
- Run `bun run check` from the repository root as the canonical full lint and type-check verification.
|
||||
- During focused iteration, run `bun typecheck` from the affected package directory (for example, `packages/core`). Never run `tsc` directly.
|
||||
- Always run `bun typecheck` from package directories (for example, `packages/core`), never `tsc` directly.
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
@@ -184,7 +183,6 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
# V2 HTTP API audit checklist
|
||||
|
||||
**Source:** `packages/protocol/openapi.json`
|
||||
**Current endpoint count:** 139
|
||||
**Last regenerated:** 2026-09-13
|
||||
|
||||
## How to use this checklist
|
||||
|
||||
Review endpoints in document order. For each endpoint, select one disposition and capture rationale or follow-up work in Notes. Mark **Reviewed** only after the disposition is agreed.
|
||||
|
||||
### Review criteria
|
||||
|
||||
- Resource and operation naming
|
||||
- HTTP method and idempotency
|
||||
- Request parameters and location scope
|
||||
- Response shape and error taxonomy
|
||||
- Authentication and authorization
|
||||
- Current production consumers
|
||||
- Stability level: public, experimental, or internal
|
||||
- Whether the generated client API is intuitive
|
||||
|
||||
### Disposition legend
|
||||
|
||||
- **Keep:** ship unchanged as a supported V2 API
|
||||
- **Change:** retain after a defined contract change
|
||||
- **Remove:** exclude from the official V2 API
|
||||
- **Experimental-only:** retain outside the stable API commitment
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Group 1: Foundation and placement (4)
|
||||
- [x] Group 2: Configuration and capability catalogs (16)
|
||||
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
|
||||
- [x] Group 4: Session lifecycle (12)
|
||||
- [x] Group 5: Session execution and inputs (11)
|
||||
- [x] Group 6: Session history and recovery (13)
|
||||
- [x] Group 7: Inbox, permissions, and forms (19)
|
||||
- [x] Group 8: Filesystem, worktrees, and VCS (12)
|
||||
- [x] Group 9: PTYs, persistent terminals, and shells (24)
|
||||
- [x] Group 10: Events, RPC, and experimental operations (6)
|
||||
|
||||
## Resolved during audit
|
||||
|
||||
### [x] `POST /api/plugin/await-activation`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Notes:** Activation timing is an internal server concern. Catalog reads remain non-blocking.
|
||||
|
||||
### [x] Location response wrappers
|
||||
|
||||
- **Decision:** Reduce generic endpoint response locations to `{ directory }`.
|
||||
- **Notes:** Full project metadata remains available from `GET /api/location`; no consumers used it from wrapped responses.
|
||||
|
||||
### [x] `GET /api/health` and `GET /api/server`
|
||||
|
||||
- **Decision:** Merge and rename
|
||||
- **Replacement:** `GET /api/info` with operation ID `server.info`.
|
||||
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
|
||||
|
||||
### [x] `GET /api/project/current`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Replacement:** `GET /api/location`, using `project` from the response.
|
||||
- **Notes:** The endpoint duplicated `Location.Info.project`; production callers were migrated.
|
||||
|
||||
### [x] `POST /api/workspace` and `DELETE /api/workspace/{workspaceID}`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Notes:** Provider-backed workspaces are not part of the V2 HTTP contract and can be introduced later. Core and the embedded SDK retain internal workspace support.
|
||||
|
||||
## Group 1: Foundation and placement
|
||||
|
||||
**Endpoints:** 4
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 001–002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
|
||||
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
|
||||
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
|
||||
|
||||
## Group 2: Configuration and capability catalogs
|
||||
|
||||
**Endpoints:** 16
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 012 | `POST` | `/api/plugin/check` | `plugin.check` | Keep | Request and response accepted as-is. |
|
||||
| [x] 013 | `POST` | `/api/plugin/update` | `plugin.update` | Keep | Request and errors accepted as-is. |
|
||||
| [x] 014 | `GET` | `/api/model` | `model.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 015 | `GET` | `/api/model/default` | `model.default` | Keep | Request and nullable response accepted as-is. |
|
||||
| [x] 016 | `GET` | `/api/provider` | `provider.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
|
||||
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
|
||||
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
|
||||
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
|
||||
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
|
||||
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
|
||||
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
|
||||
|
||||
## Group 3: Credentials, integrations, MCP, and web search
|
||||
|
||||
**Endpoints:** 22
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
|
||||
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
|
||||
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
|
||||
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
|
||||
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
|
||||
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
|
||||
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
|
||||
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
|
||||
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
|
||||
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
|
||||
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
|
||||
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
|
||||
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
|
||||
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
|
||||
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
|
||||
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | Keep | Provider availability remains location-scoped; singular resource path retained. |
|
||||
| [x] 046 | `POST` | `/api/websearch` | `websearch.query` | Keep | Unknown provider remains an invalid request; published time documented as Unix epoch milliseconds. |
|
||||
|
||||
## Group 4: Session lifecycle
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 047 | `GET` | `/api/session` | `session.list` | Keep | Existing filtering, ordering, and cursor contract retained for now. |
|
||||
| [x] 048 | `POST` | `/api/session` | `session.create` | Keep | Existing creation contract retained; model reference includes optional variant. |
|
||||
| [x] 049 | `GET` | `/api/experimental/session/stats` | `experimental.session.stats` | Experimental-only | Session analytics retained outside the stable API commitment. |
|
||||
| [x] 050 | `GET` | `/api/session/active` | `session.active` | Keep | Status record retained for future active-state expansion. |
|
||||
| [x] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | Keep | Specific session read and typed `404` retained. |
|
||||
| [x] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | Keep | Session and child deletion with typed `404` retained. |
|
||||
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
|
||||
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
|
||||
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
|
||||
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
|
||||
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
|
||||
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
|
||||
|
||||
## Group 5: Session execution and inputs
|
||||
|
||||
**Endpoints:** 11
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 059 | `POST` | `/api/session/{sessionID}/prompt` | `session.prompt` | Keep | Durable admission, delivery mode, and admit-only resume control retained. |
|
||||
| [x] 060 | `POST` | `/api/session/{sessionID}/command` | `session.command` | Change | Renamed request field from `command` to `name`; `204` retained. |
|
||||
| [x] 061 | `POST` | `/api/experimental/session/{sessionID}/skill` | `experimental.session.skill` | Experimental-only | Skill ID is now the `id` field; standalone activation remains experimental. |
|
||||
| [x] 062 | `POST` | `/api/session/{sessionID}/synthetic` | `session.synthetic` | Keep | Durable synthetic admission and delivery controls retained. |
|
||||
| [x] 063 | `POST` | `/api/session/{sessionID}/shell` | `session.shell` | Change | Caller ID is now the optimistic shell message ID; server derives its event ID. |
|
||||
| [x] 064 | `POST` | `/api/session/{sessionID}/compact` | `session.compact` | Keep | Durable compaction admission and delivery controls retained. |
|
||||
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
|
||||
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
|
||||
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
|
||||
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
|
||||
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
|
||||
|
||||
## Group 6: Session history and recovery
|
||||
|
||||
**Endpoints:** 13
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 070 | `POST` | `/api/experimental/session/import` | `experimental.session.import` | Experimental-only | Existing projected transcript import contract retained outside the stable API. |
|
||||
| [x] 071 | `GET` | `/api/experimental/session/{sessionID}/export` | `experimental.session.export` | Experimental-only | Existing projected transcript export contract retained outside the stable API. |
|
||||
| [x] 072 | `POST` | `/api/session/{sessionID}/revert/stage` | `session.revert.stage` | Keep | Existing staged history and optional file restoration behavior retained. |
|
||||
| [x] 073 | `DELETE` | `/api/session/{sessionID}/revert` | `session.revert.clear` | Change | Clearing staged revert now deletes the session revert resource. |
|
||||
| [x] 074 | `POST` | `/api/session/{sessionID}/revert/commit` | `session.revert.commit` | Keep | Explicit staged-revert commit action retained. |
|
||||
| [x] 075 | `GET` | `/api/session/{sessionID}/context` | `session.context` | Keep | Active model-context projection retained. |
|
||||
| [x] 076 | `GET` | `/api/session/{sessionID}/diff` | `session.diff` | Keep | Turn-range structured diff contract retained. |
|
||||
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
|
||||
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
|
||||
|
||||
## Group 7: Inbox, permissions, and forms
|
||||
|
||||
**Endpoints:** 19
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
|
||||
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
|
||||
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
|
||||
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
|
||||
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
|
||||
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
|
||||
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
|
||||
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
|
||||
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
|
||||
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
|
||||
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
|
||||
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
|
||||
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
|
||||
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
|
||||
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
|
||||
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
|
||||
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
|
||||
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
|
||||
| [x] 101 | — | — | — | Remove | Permission rules are updated through `session.update`. |
|
||||
|
||||
## Group 8: Filesystem, worktrees, and VCS
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
|
||||
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
|
||||
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
|
||||
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
|
||||
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
|
||||
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
|
||||
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
|
||||
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
|
||||
|
||||
## Group 9: PTYs, persistent terminals, and shells
|
||||
|
||||
**Endpoints:** 24
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
|
||||
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
|
||||
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
|
||||
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
|
||||
| [x] 136 | — | — | — | Remove | Timeout mutation remains an internal Core shell operation. |
|
||||
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
|
||||
|
||||
## Group 10: Events, RPC, and experimental operations
|
||||
|
||||
**Endpoints:** 6
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
|
||||
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
|
||||
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
|
||||
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
|
||||
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
|
||||
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
@@ -6,7 +6,6 @@ export function createWebApp(domain: string) {
|
||||
$app.stage === "beta"
|
||||
? {
|
||||
OPENCODE_CHANNEL: "beta",
|
||||
VITE_OPENCODE_SERVER_MODE: "none",
|
||||
VITE_SENTRY_ENVIRONMENT: "beta",
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APP=opencode
|
||||
SOURCE_APP=opencode
|
||||
APP=opencode2
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
@@ -23,7 +22,7 @@ Options:
|
||||
Examples:
|
||||
curl -fsSL https://opencode.ai/v2/install | bash
|
||||
curl -fsSL https://opencode.ai/v2/install | bash -s -- --version 0.0.0-beta-17236
|
||||
./install --binary /path/to/opencode
|
||||
./install --binary /path/to/opencode2
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -166,30 +165,22 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package_scope="@opencode"
|
||||
if [ -z "$requested_version" ]; then
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
|
||||
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/beta || true)
|
||||
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
|
||||
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
|
||||
|
||||
if [ -z "$specific_version" ] || [ -z "$package" ]; then
|
||||
if [ -z "$specific_version" ]; then
|
||||
echo -e "${RED}Failed to fetch version information${NC}"
|
||||
exit 1
|
||||
fi
|
||||
package_scope="${package%/cli}"
|
||||
else
|
||||
# Strip leading 'v' if present
|
||||
requested_version="${requested_version#v}"
|
||||
specific_version=$requested_version
|
||||
fi
|
||||
|
||||
package_name="$package_scope/cli-$target"
|
||||
http_status=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/$package_scope%2fcli-$target/$specific_version" || true)
|
||||
# Older clients install the minimum release before they can migrate package names.
|
||||
if [ "$http_status" = "404" ] && [ -n "$requested_version" ]; then
|
||||
package_name="@opencode-ai/cli-$target"
|
||||
http_status=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/@opencode-ai%2fcli-$target/$specific_version" || true)
|
||||
fi
|
||||
package_name="@opencode-ai/cli-$target"
|
||||
http_status=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/@opencode-ai%2fcli-$target/$specific_version" || true)
|
||||
if [ "$http_status" = "404" ]; then
|
||||
echo -e "${RED}Error: Version ${specific_version} is not available for $target${NC}"
|
||||
echo -e "${MUTED}Available versions: https://www.npmjs.com/package/$package_name?activeTab=versions${NC}"
|
||||
@@ -202,9 +193,9 @@ else
|
||||
|
||||
filename="cli-$target-$specific_version.tgz"
|
||||
url="https://registry.npmjs.org/$package_name/-/$filename"
|
||||
binary_name="$SOURCE_APP"
|
||||
binary_name="$APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
binary_name="$SOURCE_APP.exe"
|
||||
binary_name="$APP.exe"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -231,7 +222,12 @@ check_version() {
|
||||
installed_version="${installed_version##* }"
|
||||
installed_version="${installed_version#v}"
|
||||
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
if [[ "$installed_version" != "$specific_version" ]]; then
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
else
|
||||
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -336,46 +332,15 @@ download_and_install() {
|
||||
fi
|
||||
|
||||
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
|
||||
local installed_binary="$APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
installed_binary="$APP.exe"
|
||||
fi
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR"
|
||||
chmod 755 "${INSTALL_DIR}/$binary_name"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
install_from_binary() {
|
||||
print_message info "\n${MUTED}Installing ${NC}$APP ${MUTED}from: ${NC}$binary_path"
|
||||
local installed_binary="$APP"
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) installed_binary="$APP.exe" ;;
|
||||
esac
|
||||
cp "$binary_path" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
}
|
||||
|
||||
install_legacy_shim() {
|
||||
local shim_os="${os:-}"
|
||||
if [[ -z "$shim_os" ]]; then
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) shim_os="windows" ;;
|
||||
esac
|
||||
fi
|
||||
rm -f "$INSTALL_DIR/opencode2" "$INSTALL_DIR/opencode2.exe" "$INSTALL_DIR/opencode2.cmd"
|
||||
if [[ "$shim_os" == "windows" ]]; then
|
||||
cat > "$INSTALL_DIR/opencode2.cmd" <<'EOF'
|
||||
@echo off
|
||||
"%~dp0opencode.exe" %*
|
||||
exit /b %errorlevel%
|
||||
EOF
|
||||
return
|
||||
fi
|
||||
cat > "$INSTALL_DIR/opencode2" <<'EOF'
|
||||
#!/bin/sh
|
||||
exec "$(dirname "$0")/opencode" "$@"
|
||||
EOF
|
||||
chmod 755 "$INSTALL_DIR/opencode2"
|
||||
cp "$binary_path" "${INSTALL_DIR}/$APP"
|
||||
chmod 755 "${INSTALL_DIR}/$APP"
|
||||
}
|
||||
|
||||
if [ -n "$binary_path" ]; then
|
||||
@@ -384,7 +349,6 @@ else
|
||||
check_version
|
||||
download_and_install
|
||||
fi
|
||||
install_legacy_shim
|
||||
|
||||
|
||||
add_to_path() {
|
||||
@@ -481,7 +445,7 @@ echo -e ""
|
||||
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
|
||||
echo -e ""
|
||||
echo -e "cd <project> ${MUTED}# Open directory${NC}"
|
||||
echo -e "opencode ${MUTED}# Run command${NC}"
|
||||
echo -e "opencode2 ${MUTED}# Run command${NC}"
|
||||
echo -e ""
|
||||
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/v2/docs"
|
||||
echo -e ""
|
||||
|
||||
+2
-2
@@ -88,9 +88,9 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
cd packages/desktop
|
||||
|
||||
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode/", ""))')
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
|
||||
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LQ1GAz1qF4R5P4j/kkgUygsQvxm7KStVlMf24nmyq44=",
|
||||
"aarch64-linux": "sha256-PsNR3VaClA1O1vSE0z/Hb3XRT1pb6PvNRXuK+giIx6s=",
|
||||
"aarch64-darwin": "sha256-7VCS+GT7tDYAMgqNCnLh1zvHAZkLTnJJ4vtfM88ruT8=",
|
||||
"x86_64-darwin": "sha256-HyxXjiFVd8vcKvSb/BuHAY5Jj3MGgdXEvuhDuglp4ss="
|
||||
"x86_64-linux": "sha256-is89RjF5ijUsAaJC+d9BaJaV27pGyEPyUNg0DJcv9eg=",
|
||||
"aarch64-linux": "sha256-gyExE7mLVzAZ39kApwDELrFRZjtdwy3kWdYaeS/MY0Y=",
|
||||
"aarch64-darwin": "sha256-uMrja7eBy07eUEaF12Ip8g/Jgj9PPNZoiGJGMFUi9vA=",
|
||||
"x86_64-darwin": "sha256-eZXjKLsLmMeBaK5aTUL2sS0ffQeEgB6/oalZAtxBwp0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,11 @@ stdenvNoCC.mkDerivation {
|
||||
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
|
||||
lib.fileset.unions [
|
||||
../packages
|
||||
../services
|
||||
../bun.lock
|
||||
../package.json
|
||||
../patches
|
||||
../install # required by desktop build (cli.rs include_str!)
|
||||
../.github/TEAM_MEMBERS # required by @opencode/script
|
||||
../.github/TEAM_MEMBERS # required by @opencode-ai/script
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
+4
-14
@@ -8,7 +8,6 @@
|
||||
makeBinaryWrapper,
|
||||
models-dev,
|
||||
ripgrep,
|
||||
wayland,
|
||||
installShellFiles,
|
||||
versionCheckHook,
|
||||
writableTmpDirAsHomeHook,
|
||||
@@ -63,10 +62,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 dist/cli-*/bin/opencode $out/bin/opencode
|
||||
install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2
|
||||
|
||||
# OpenTUI dlopens Wayland for clipboard images.
|
||||
wrapProgram $out/bin/opencode \
|
||||
wrapProgram $out/bin/opencode2 \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath (
|
||||
[
|
||||
@@ -75,21 +73,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
# bun runs sysctl to detect if running on rosetta2
|
||||
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
||||
)
|
||||
} ${lib.optionalString stdenvNoCC.hostPlatform.isLinux ''
|
||||
--prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [ wayland ]}
|
||||
''}
|
||||
|
||||
ln -s opencode $out/bin/opencode2
|
||||
}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||
# trick yargs into also generating zsh completions
|
||||
installShellCompletion --cmd opencode \
|
||||
--bash <($out/bin/opencode completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
|
||||
|
||||
installShellCompletion --cmd opencode2 \
|
||||
--bash <($out/bin/opencode2 completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
|
||||
@@ -111,7 +101,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
description = "The open source coding agent";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode";
|
||||
mainProgram = "opencode2";
|
||||
inherit (node_modules.meta) platforms;
|
||||
};
|
||||
})
|
||||
|
||||
+12
-13
@@ -2,20 +2,18 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.15",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:www": "bun run --cwd services/www dev",
|
||||
"dev:www": "bun run --cwd packages/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||
"lint": "oxlint",
|
||||
@@ -24,7 +22,6 @@
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"check": "bun run lint && bun run typecheck",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
@@ -37,7 +34,6 @@
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"services/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*"
|
||||
],
|
||||
@@ -116,7 +112,6 @@
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -133,8 +128,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/script": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
@@ -151,6 +146,10 @@
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
@@ -173,13 +172,13 @@
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch",
|
||||
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch"
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-32
@@ -10,18 +10,9 @@
|
||||
|
||||
## Conventions
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
|
||||
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.inlineMedia` / `requireInlineMedia` / `mediaUrl` / `MediaInput.refID` rather than re-deriving base64 or URL handling.
|
||||
|
||||
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
|
||||
|
||||
Nothing in `src/*` except `src/promise.ts` may know about Promises. `@opencode/ai/promise` (`AI.make({ layer? })`, default `ai`) is the single Promise/`AsyncIterable` surface for LLM and media; it runs the Effect APIs in one `ManagedRuntime` and rethrows `AIError` unchanged.
|
||||
|
||||
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
|
||||
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -90,20 +81,10 @@ export const route = Route.make({
|
||||
|
||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider owns a small `Route.make(...)` composition instead of a protocol clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
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 supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
|
||||
|
||||
### Media Routes
|
||||
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
|
||||
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `Generation.AwaitOptions` (`{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
|
||||
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `MediaProtocol.incomplete`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `MediaProtocol.decodeFrame` and raise stream-time failures with `MediaProtocol.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
|
||||
|
||||
Transcription uses all three kinds (OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued): every route carries its `kind` and `TranscriptionClient` dispatches on it. Bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
|
||||
|
||||
### URL Construction
|
||||
|
||||
`Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Generic OpenAI-compatible routes have no canonical URL and require configuration before execution.
|
||||
@@ -112,12 +93,11 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
|
||||
|
||||
### Provider Facades
|
||||
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, later `.video` / `.speech` / `.transcription`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
|
||||
|
||||
```ts
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
const image = openai.image("gpt-image-2")
|
||||
|
||||
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
||||
const deployment = azure.responses("my-deployment")
|
||||
@@ -135,20 +115,18 @@ Keep provider facades small and explicit:
|
||||
- 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).
|
||||
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
|
||||
- Give every named provider its own file and top-level export. Keep its endpoint, auth defaults, and route setup in that file. Compose shared protocols directly; do not nest named provider presets under generic compatible facades or keep their endpoints in a shared provider profile registry.
|
||||
|
||||
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
||||
|
||||
### Provider Package Entrypoints
|
||||
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
|
||||
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode/ai/providers/openai/responses"
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
```
|
||||
|
||||
@@ -185,10 +163,6 @@ Native chronological system messages are route/model-specific. Open Responses lo
|
||||
|
||||
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
|
||||
|
||||
### Effort Updates
|
||||
|
||||
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
|
||||
|
||||
### Tools
|
||||
|
||||
Tool loops are represented in common messages and events:
|
||||
|
||||
+88
-740
File diff suppressed because it is too large
Load Diff
@@ -1,444 +0,0 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–3 implemented (Speech and Transcription); phases 4–5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
`@opencode/ai` becomes the one package you reach for to generate anything: text, images, video, speech, transcripts, and later music and realtime. The LLM surface already exists and is shaped by three constraints: Effect-first, used by OpenCode Core, usable externally. Media has a different priority order: **external DX first**, Effect and Promise as peers, Core as one consumer among many.
|
||||
|
||||
The design below is derived from a survey of the raw provider APIs (OpenAI, Gemini/Veo/Imagen, xAI, Stability, BFL, fal, Replicate, Runway, Luma, Kling, MiniMax, ElevenLabs, Deepgram, Cartesia, AssemblyAI, Lyria) and of existing multi-provider SDKs.
|
||||
|
||||
## What the survey forces
|
||||
|
||||
1. **Three execution shapes, everywhere.** Inline sync (OpenAI images, all TTS, Gemini), async job with polling or webhook (every video provider, BFL, fal, Replicate, AssemblyAI), and bidirectional streams (ElevenLabs/Cartesia/Deepgram WS, realtime). Video has no sync provider at all.
|
||||
2. **Output is never just bytes.** base64, signed URLs with TTLs from 10 minutes (BFL) to 2 days (Veo), URLs that need auth plus redirect (Veo), separate download endpoints (Sora `/content?variant=`), raw bodies (Stability, TTS). Multi-output is the norm.
|
||||
3. **Inputs have roles.** First/last frame, mask, style/subject reference, source video for edit/extend, reference audio, prior generation id, provider-side file handles (`file_id`, `gs://`, `runway://`, `mm_file://`).
|
||||
4. **Partial streaming is modality-specific.** Images: a few whole partial frames. Audio: ordered chunks plus timestamp events. Jobs: status/progress/logs. Video: none.
|
||||
5. **Usage is a union**: tokens, seconds, characters (often only in headers), credits, compute time.
|
||||
6. **Moderation can be partial success** (Veo strips audio but returns video). Deprecations are constant (Sora API shuts down 2026-09-24, Imagen on Gemini API 2026-08-17).
|
||||
|
||||
## Where existing SDKs are weak and we should not be
|
||||
|
||||
- No streaming TTS.
|
||||
- Video handles are experimental start/status pairs; the polling loop lives inside the generate call.
|
||||
- Unsupported inputs become silent warnings arrays, so a request can succeed while dropping your mask.
|
||||
- `n` is fanned out into hidden parallel calls, which obscures cost and idempotency.
|
||||
- Each modality has its own bespoke result type; the file abstraction is a lazy base64/bytes pair with no URL, expiry, or provider ref.
|
||||
- Effect's own `unstable/ai` has no media generation. Nothing in the Effect ecosystem owns this.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Same shape as LLM.** `X.request(...)` → Schema class; `X.generate(request)` / `X.stream(request)`; `XClient.Service` + `layer`; typed `AIError`. If you know `LLM`, you know `Video`.
|
||||
- **Execution shape is route policy, not API shape.** `Image.generate` returns an image whether the provider is inline or queued. Job control is available uniformly when you want it.
|
||||
- **Errors, not warnings.** Unsupported common fields fail at the protocol boundary with a typed `AIError`, as the LLM routes do today. Provider-side partial results (filtered audio, moderated sample) surface as `notices` on the response, never as silent drops.
|
||||
- **One asset type in, one asset type out**, shared with LLM messages and tool results.
|
||||
- **Typed per-model options**, no hidden fan-out, no implicit retries that spend money.
|
||||
- **Promise API is one mechanism for the whole package**, not a media-only wrapper.
|
||||
- **One construction path per model.** Media models come from per-modality selectors on the configured facade (`openai.image("gpt-image-2")`), the same shape as `openai.responses("gpt-5")`.
|
||||
|
||||
## Public API
|
||||
|
||||
### Model selection
|
||||
|
||||
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `openai.image(id)` today, `.video(id)` / `.speech(id)` / `.transcription(id)` as those modalities land — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
|
||||
|
||||
```ts
|
||||
import { OpenAI, Google } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey }) // OpenAI(...) alone uses env auth (OPENAI_API_KEY)
|
||||
|
||||
LLM.request({ model: openai.responses("gpt-5"), prompt })
|
||||
Image.request({ model: openai.image("gpt-image-2"), prompt })
|
||||
Video.request({ model: google.video("veo-3.1-generate-preview"), prompt })
|
||||
Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
|
||||
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
|
||||
```
|
||||
|
||||
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two APIs for one modality, the selectors stay explicit (`openai.chat`, a future `google.imagen`), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native since Imagen on the Gemini API shuts down 2026-08-17). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
|
||||
|
||||
### `Media` — the asset type
|
||||
|
||||
Replaces `MediaPart.data: string | Uint8Array`, `ImageInput`, `GeneratedImage`, and aligns `Tool.FileContent`.
|
||||
|
||||
```ts
|
||||
import { Media } from "@opencode/ai"
|
||||
|
||||
Media.Source =
|
||||
| { type: "bytes"; data: Uint8Array; mediaType: string }
|
||||
| { type: "base64"; data: string; mediaType: string }
|
||||
| { type: "url"; url: string; mediaType?: string; expiresAt?: number }
|
||||
| { type: "ref"; provider: ProviderID; id: string; mediaType?: string } // file_id, gs://, runway://, prior generation
|
||||
|
||||
class Media.Asset {
|
||||
readonly source: Media.Source
|
||||
readonly mediaType: string // always resolved (sniffed when the provider omits it)
|
||||
readonly kind: "image" | "video" | "audio" | "document" | "other"
|
||||
readonly info?: { width?; height?; durationSeconds?; sampleRate?; channels?; encoding?; format? }
|
||||
readonly expiresAt?: number
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
readonly headers?: Record<string, string> // transient download credentials (Veo); never in source/JSON
|
||||
|
||||
bytes(): Effect<Uint8Array, AIError, RequestExecutor.Service> // downloads/decodes lazily, cached
|
||||
base64(): Effect<string, AIError, RequestExecutor.Service>
|
||||
dataUrl(): Effect<string, AIError, RequestExecutor.Service>
|
||||
materialize(): Effect<Media.Asset, AIError, RequestExecutor.Service> // url/ref → bytes, before the URL dies
|
||||
}
|
||||
|
||||
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
|
||||
Media.url(url, options?) Media.ref(provider, id)
|
||||
Media.file(path) // Bun/Node: reads + sniffs; Effect FileSystem variant for layers
|
||||
Media.write(asset, path) // convenience, uses FileSystem
|
||||
```
|
||||
|
||||
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
|
||||
|
||||
### Modality namespaces
|
||||
|
||||
Each namespace mirrors `LLM` exactly.
|
||||
|
||||
```ts
|
||||
import { Image, Video, Speech, Transcription } from "@opencode/ai"
|
||||
import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
|
||||
```
|
||||
|
||||
#### Image
|
||||
|
||||
```ts
|
||||
const request = Image.request({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
images: [Media.file("./ref.png")], // references / edit sources
|
||||
mask: Media.file("./mask.png"),
|
||||
n: 2,
|
||||
size: "1536x1024", // or aspectRatio: "3:2"
|
||||
seed: 7,
|
||||
format: "webp",
|
||||
providerOptions: { quality: "high", background: "transparent" }, // typed per model
|
||||
})
|
||||
|
||||
const response = yield* Image.generate(request) // ImageResponse
|
||||
response.image // Media.Asset (first)
|
||||
response.images // Media.Asset[]
|
||||
response.usage // Usage union (see below)
|
||||
response.notices // moderation / partial-result notices
|
||||
|
||||
yield* Image.stream(request) // Stream<ImageEvent>
|
||||
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
|
||||
```
|
||||
|
||||
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
|
||||
|
||||
#### Video
|
||||
|
||||
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
|
||||
|
||||
```ts
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: Media.file("./start.png"), last: Media.file("./end.png") },
|
||||
references: [Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
yield* Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
```
|
||||
|
||||
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
|
||||
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
|
||||
lowering per provider: Veo takes inline media only and rejects `audio: false` and `n > 1`; xAI rejects `seed` and
|
||||
`negativePrompt` and routes a `video` input to edits or (`providerOptions.mode: "extend"`) extensions; fal rejects
|
||||
`durationSeconds`, `references`, and `frames.last` because the field names and enums differ per model; Runway passes
|
||||
`aspectRatio` through as its pixel `ratio` and rejects `n`.
|
||||
|
||||
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
|
||||
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
|
||||
|
||||
#### Speech (TTS)
|
||||
|
||||
Shipped in phase 3 (`src/speech.ts`, `src/speech-client.ts`, protocols `openai-speech`, `google-speech`,
|
||||
`elevenlabs-speech`, `cartesia-speech`, `deepgram-speech`; new `ElevenLabs`, `Cartesia`, and `Deepgram` facades).
|
||||
|
||||
```ts
|
||||
const request = Speech.request({
|
||||
model: elevenlabs.speech("eleven_flash_v2_5"),
|
||||
text: "Hello from OpenCode.",
|
||||
voice: "JBFqnCBsd6RMkjVDRZzb", // provider-native identifier, or { id }
|
||||
format: "mp3", // mp3 | wav | pcm | opus | aac | flac | (string & {})
|
||||
speed: 1.0,
|
||||
language: "en",
|
||||
instructions: "Warm, unhurried.", // only OpenAI; elsewhere fails typed
|
||||
timestamps: true, // request alignment; routes without it fail typed
|
||||
providerOptions: { voice_settings: { stability: 0.5 } },
|
||||
})
|
||||
|
||||
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
|
||||
yield* Speech.stream(request) // Stream<SpeechEvent>: audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
|
||||
```
|
||||
|
||||
Execution is `MediaProtocol.stream` for every provider: one request whose body is framed and folded by a `step`
|
||||
state machine, with `generate` running the same stream and collecting it. The route submits the request with its
|
||||
`mode` (`"generate" | "stream"`), which lets one provider stay one protocol — OpenAI adds `stream_format: "sse"` (except `tts-1`/`tts-1-hd`, which stream raw bytes), ElevenLabs appends
|
||||
`/stream`, Cartesia switches `/tts/bytes` to `/tts/sse`, Gemini switches `generateContent` to
|
||||
`streamGenerateContent`. The terminal `finish` event carries the assembled asset (every provider's stream is
|
||||
concatenable chunks), so stream consumers also get the whole file and `generate` is just "take `finish`, gather
|
||||
`timestamps`". The cost is memory: a stream holds every chunk until `finish`, so even a consumer that only plays deltas
|
||||
keeps the whole clip in memory. That is bounded by the providers' input text limits (a few minutes of audio); a
|
||||
long-form or session API would need an opt-out.
|
||||
|
||||
**Voice.** `voice?: string | { id: string }`. A string is passed through as the provider's native identifier — a
|
||||
name on OpenAI and Gemini, a voice id on ElevenLabs (path segment) and Cartesia. `{ id }` selects an OpenAI custom
|
||||
voice and is treated as the plain string on routes that do not distinguish custom from built-in. Deepgram's voice is
|
||||
the model id (`aura-2-thalia-en`), so `voice` is `unsupported` there. There is no cross-provider voice catalog or
|
||||
name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfig`) and per-voice settings
|
||||
(ElevenLabs `voice_settings`) go through `providerOptions`.
|
||||
|
||||
**Format and PCM.** `format` is container-level; provider sample rates and bitrates live under `providerOptions`
|
||||
(ElevenLabs `outputFormat`, Cartesia `sampleRate`/`bitRate`/`encoding`, Deepgram `encoding`/`container`/`sampleRate`/
|
||||
`bitRate`). Each protocol maps `format` to its native value (ElevenLabs `mp3_44100_128`/`pcm_24000`/`wav_24000`/
|
||||
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
|
||||
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
|
||||
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
|
||||
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini returns PCM only, so any
|
||||
other `format` is rejected rather than wrapped as WAV by the route. Every `format` value a route cannot produce (unknown
|
||||
to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but PCM on Gemini) fails the same way as an
|
||||
unsupported field: `UnsupportedOperation` with `operation: "media.format"`.
|
||||
|
||||
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
|
||||
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
|
||||
`generate` with timestamps collects the SSE stream). OpenAI, Gemini, and Deepgram reject it.
|
||||
|
||||
Common-field lowering per provider:
|
||||
|
||||
| Provider | `voice` | `speed` | `language` | `instructions` | `timestamps` | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | `voice` (name or `{ id }`) | `speed` | unsupported | `instructions` | unsupported | `tokens` from SSE `speech.audio.done` only |
|
||||
| Gemini | `prebuiltVoiceConfig.voiceName` | unsupported | `speechConfig.languageCode` | unsupported (direct in text) | unsupported | `tokens` from `usageMetadata` |
|
||||
| ElevenLabs | path voice id (required) | `voice_settings.speed` | `language_code` | unsupported | `with-timestamps` | `credits` from `character-cost` header |
|
||||
| Cartesia | `voice` (required) | `generation_config.speed` | `language` | unsupported | `add_timestamps` | none |
|
||||
| Deepgram | unsupported (voice is the model) | `speed` query | unsupported | unsupported | unsupported | `characters` from `dg-char-count` header |
|
||||
|
||||
Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incrementally over a WebSocket (ElevenLabs
|
||||
`stream-input`, Cartesia WebSocket contexts, Deepgram WebSocket speak) — is a separate scoped resource, not part of
|
||||
`generate`/`stream`, and ships with the realtime work in phase 5.
|
||||
|
||||
#### Transcription (STT)
|
||||
|
||||
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
|
||||
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `assemblyai-transcription`; new `AssemblyAI`
|
||||
facade).
|
||||
|
||||
```ts
|
||||
const request = Transcription.request({
|
||||
model: openai.transcription("gpt-4o-transcribe-diarize"),
|
||||
audio: yield* Media.file("./call.wav"),
|
||||
language: "en", // provider-native passthrough
|
||||
timestamps: "segment", // none | segment | word
|
||||
diarize: true,
|
||||
speakers: 2, // expected count, hint only (AssemblyAI)
|
||||
providerOptions: { known_speaker_names: ["agent"] },
|
||||
})
|
||||
|
||||
const response = yield* Transcription.generate(request)
|
||||
response.text; response.segments; response.words; response.language; response.durationSeconds; response.usage
|
||||
yield* Transcription.stream(request) // Stream<TranscriptionEvent>: generation-queued | generation-progress | text-delta | segment | finish
|
||||
const generation = yield* Transcription.start(request) // queued routes only
|
||||
yield* Transcription.resume(model, token)
|
||||
```
|
||||
|
||||
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
|
||||
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
|
||||
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
|
||||
`<Options, Frame, State>`, `<Options, Token>`) and composes through `MediaRoute.inline` / `stream` / `queued`; and
|
||||
`TranscriptionClient` dispatches on `route.kind`. `generate` on a queued route is `start` then `await`; `stream` on an
|
||||
inline route is the response as a single `finish`, and on a queued route it is the status observations followed by
|
||||
`finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
|
||||
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
|
||||
`collect` is just "take `finish`".
|
||||
|
||||
The route layer gained a `binary` body with array-valued `query` (Deepgram) and `Queued.start.prepare` (AssemblyAI's
|
||||
upload); `packages/ai/AGENTS.md` (Media Routes) describes both.
|
||||
|
||||
Settled rules:
|
||||
|
||||
- **Timestamps.** A granularity the selected route or model cannot produce fails as `UnsupportedOperation`
|
||||
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram and AssemblyAI always return
|
||||
words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
|
||||
word offsets, so segment timestamps and diarization also request word offsets there.
|
||||
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
|
||||
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A` — with no
|
||||
cross-provider speaker model. `speakers` is a hint; only AssemblyAI (`speakers_expected`) accepts it.
|
||||
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
|
||||
AssemblyAI `language_code`). `response.language` is the provider's own value, lowercased but not normalized: an
|
||||
ISO code on most routes, `english` from whisper-1, `en_us` from AssemblyAI. Deepgram and AssemblyAI assume English
|
||||
unless asked to detect, so a missing `language` enables their detection.
|
||||
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
|
||||
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
|
||||
turns (one part per turn), which join with a space.
|
||||
- **Streaming inline providers** emit only `finish`; deltas are never faked.
|
||||
- **Units.** AssemblyAI milliseconds and Gemini protobuf durations (`"0.400s"`) are normalized to seconds at the
|
||||
protocol boundary.
|
||||
|
||||
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | stream (`stream: true` in `stream` mode) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model; streaming on `whisper-1` | `tokens` or `seconds` |
|
||||
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
|
||||
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
|
||||
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
|
||||
|
||||
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
|
||||
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
|
||||
with the realtime work in phase 5. ElevenLabs Scribe is not implemented yet.
|
||||
|
||||
### `Generation` — shared async execution
|
||||
|
||||
```ts
|
||||
class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect; pollHint? } closed over the decoded token
|
||||
readonly token: unknown // route-owned serializable JSON
|
||||
readonly status: "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
readonly progress?: number // 0..1, normalized
|
||||
readonly position?: number
|
||||
readonly expiresAt?: number
|
||||
refresh(): Effect<Generation<Response>, AIError>
|
||||
result(): Effect<Response, AIError>
|
||||
await(options?: AwaitOptions): Effect<Response, AIError>
|
||||
cancel(): Effect<void, AIError>
|
||||
events(options?: AwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
}
|
||||
|
||||
AwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
|
||||
```
|
||||
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, and Replicate are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
Usage =
|
||||
| { type: "tokens"; input; output; total; details? }
|
||||
| { type: "seconds"; seconds }
|
||||
| { type: "characters"; characters }
|
||||
| { type: "credits"; credits }
|
||||
| { type: "compute"; seconds }
|
||||
```
|
||||
|
||||
Header-only usage (ElevenLabs `character-cost`, Deepgram `dg-char-count`) is lifted into `usage` by the route.
|
||||
|
||||
### Promise API — `@opencode/ai/promise`
|
||||
|
||||
Mirrors the `packages/plugin/src/effect` and `packages/plugin/src/promise` split that already exists in this repo. One mechanism for LLM and media.
|
||||
|
||||
```ts
|
||||
import { AI } from "@opencode/ai/promise"
|
||||
|
||||
const ai = AI.make() // ManagedRuntime over RequestExecutor.fetchLayer + all clients
|
||||
// AI.make({ layer }) to inject a custom executor / recorder / middleware
|
||||
|
||||
const image = await ai.image.generate({ model, prompt })
|
||||
await image.image.bytes()
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text, voice })) { … }
|
||||
|
||||
const generation = await ai.video.start({ model, prompt })
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
const resumed = ai.video.resume(model, JSON.parse(saved))
|
||||
|
||||
const text = await ai.llm.generate({ model, prompt }) // closes today's gap: LLM has no promise API either
|
||||
for await (const event of ai.llm.stream(request)) { … }
|
||||
|
||||
await ai.dispose()
|
||||
```
|
||||
|
||||
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. `AbortSignal` maps to interruption. Nothing in `src/*` except this entrypoint knows about promises.
|
||||
|
||||
### Providers
|
||||
|
||||
Existing facades gain per-modality selectors; the modality routes each facade provides:
|
||||
|
||||
| Facade | llm | image | video | speech | transcription | other |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `OpenAI` | responses (default), chat | Images API | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
|
||||
| `Google` | Gemini | Gemini-native (default), `imagen` | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
|
||||
| `XAI` | ✓ | ✓ | ✓ | | | |
|
||||
| `ElevenLabs` | | | | ✓ | Scribe | soundEffect, music |
|
||||
| `Cartesia` | | | | ✓ | | |
|
||||
| `Deepgram` | | | | Aura | ✓ | |
|
||||
| `Fal` | | ✓ | ✓ | | | |
|
||||
| `AssemblyAI` | | | | | ✓ (queued) | |
|
||||
| `Replicate`, `Runway`, `Luma`, `Kling`, `MiniMax`, `BlackForestLabs`, `Stability` | | per provider | | | | |
|
||||
|
||||
New facades follow the existing one-file-per-provider rule. Package entrypoints are modality-specific, such as `@opencode/ai/providers/openai/images`, and return the concrete model.
|
||||
|
||||
`ImageModel<Options>` already gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, `TranscriptionModel` follow the same generic. A shared `MediaModel` union is what `Generation` and the promise client key on.
|
||||
|
||||
### Routes and protocols
|
||||
|
||||
Media does not fit the LLM four-axis route (SSE frames → event state machine) except for streaming TTS/STT. Reuse `Endpoint`, `Auth`, `Framing`, `RequestExecutor`, and add media protocol kinds:
|
||||
|
||||
- `MediaProtocol.inline` — `body.from(request)` (JSON, multipart, or query), `response.decode(response)` (JSON, or binary body → `Media.Asset`).
|
||||
- `MediaProtocol.queued` — `start` (body + decode to `{ token, snapshot }`), `status`, `result`, optional `cancel`, `pollHint`, and a `token` codec. `result` is always a separate GET (against the status document for Veo/xAI/Runway, fal's `response_url` otherwise) so `await` after `start` and after `resume` share one path. `PollContext.auth` hands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transient `Media.Asset.headers`, never part of `source`. There is no separate `download` step: `Media.Asset.bytes()` downloads through the executor with those headers. `MediaRoute.inline(...)` / `MediaRoute.queued(...)` compose each kind with endpoint and auth; the queued route decodes the token once and hands `Generation` a token-free `{ status, result, cancel? }`.
|
||||
- `MediaProtocol.stream` — `body.from(request)` over the request plus its `mode`, `frames` (a function that picks the framing for the call: `Framing.sse`, `lines`, `document`, or the raw bytes), fresh per-response `initial()` state, `step` emitting modality events, and `finish(state, context)` — with the observed response for header-only usage — emitting exactly one terminal event or failing as an incomplete stream. The route fills `reason.http` on stream errors. `MediaRoute.stream(...)` exposes `stream` and `generate` (the same stream folded by the modality's `collect`).
|
||||
|
||||
`MediaRoute.inline` / `MediaRoute.queued` / `MediaRoute.stream` compose one protocol kind with endpoint/auth and tag the route with its `kind`; `ImageModel`/`VideoModel`/`SpeechModel`/`TranscriptionModel` share the `MediaModel` base (`src/media-model.ts`).
|
||||
|
||||
### LLM integration
|
||||
|
||||
- `MediaPart` becomes `{ type: "media"; media: Media.Asset; … }` so protocols branch on `kind` and can pass `url`/`ref` sources through natively (OpenAI `image_url`, Gemini `fileData`).
|
||||
- New `LLMEvent`s: `media { media: Media.Asset }` so Gemini inline image output is first-class instead of dropped. OpenAI Responses `image_generation_call` keeps its single carrier — the provider-executed `tool-result` with `file` content — because Core consumes hosted tool-result content today and has no `media` event handling yet; it switches to the `media` carrier when Core adopts the event, so the image is never emitted twice.
|
||||
- `Message.assistant([...])` accepts media parts; Gemini multi-turn image editing replays them.
|
||||
- `Tool.FileContent` aligns with `Media.Source`.
|
||||
|
||||
## Decisions
|
||||
|
||||
All settled:
|
||||
|
||||
1. **Per-modality selectors** (`openai.image(id)`, `.video`, `.speech`, `.transcription`) name media models, mirroring `openai.responses(id)`. The one-word overlap with the request namespace is accepted over a callable-facade `ModelRef` as a second construction path.
|
||||
2. **`providerOptions` everywhere** (rename current `Image.options`) for consistency with LLM.
|
||||
3. **No hidden `n` fan-out.** `n` lowers natively; routes that cannot do `n > 1` fail typed. Callers use `Effect.all` / `Promise.all` explicitly.
|
||||
4. **Errors over warnings** for unsupported common fields; `notices` for provider-side partial results only.
|
||||
5. **`Media.Asset` is a class** (lazy bytes, cached) with `Media.Source` as the serializable Schema for wire/persistence. `Asset.from(source)` / `asset.source` round-trip losslessly. Same pattern as `LanguageModel` today.
|
||||
6. **Promise entrypoint**: `@opencode/ai/promise` exporting `AI.make(options?: { layer? })` plus a module-level default `ai` for scripts, covering LLM too.
|
||||
7. **Modality set for v1**: `Image`, `Video`, `Speech`, `Transcription`. `Music`/`SoundEffect` and `session` (bidirectional WS, realtime) are designed-for but deferred.
|
||||
8. **Sora is skipped** (API shuts down 2026-09-24). Video launches with Veo, xAI, fal, Runway.
|
||||
|
||||
## Build order
|
||||
|
||||
Foundation + Image ship together as the reference implementation, serially. Video, Speech, and Transcription then proceed in parallel on separate branches. Image jobs and partial streaming come last, after Video has hardened `Generation`.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
|
||||
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
|
||||
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Pending: ElevenLabs Scribe. Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
|
||||
4. **Image queued routes and partials** — BFL, fal, Replicate, Stability; OpenAI `partial_images` streaming.
|
||||
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
|
||||
|
||||
Core adoption (session attachments beyond png/jpeg/gif/webp/pdf, image-generation tool, TUI rendering) comes after phase 1 and is a Core concern.
|
||||
@@ -1,19 +1,7 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import {
|
||||
Image,
|
||||
ImageClient,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
Media,
|
||||
Message,
|
||||
ProviderID,
|
||||
Tool,
|
||||
ToolRuntime,
|
||||
} from "@opencode/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
/**
|
||||
* A runnable walkthrough of the LLM package use-site API.
|
||||
@@ -28,18 +16,15 @@ import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||
|
||||
// 1. Configure a provider. The configured facade records provider identity,
|
||||
// deployment options, authentication, and defaults. Per-modality selectors pick
|
||||
// the API: `.responses(...)` / `.chat(...)` for LLM calls and `.image(...)` for
|
||||
// image generation.
|
||||
const openai = OpenAI.configure({
|
||||
// 1. Pick a model. The provider helper records provider identity, protocol
|
||||
// choice, capabilities, deployment options, authentication, and defaults.
|
||||
const model = OpenAI.configure({
|
||||
apiKey,
|
||||
generation: { maxTokens: 160 },
|
||||
providerOptions: {
|
||||
store: false,
|
||||
},
|
||||
})
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
}).model("gpt-4o-mini")
|
||||
|
||||
// 2. Build a provider-neutral request. This is useful when reusing one request
|
||||
// across generate and stream examples.
|
||||
@@ -224,39 +209,18 @@ const FakeEcho = {
|
||||
}),
|
||||
}
|
||||
|
||||
// 8. Image generation uses the same facade and the same request/generate shape.
|
||||
// `response.image` is a `Media.Asset`: bytes decode lazily and are cached, and
|
||||
// `Media.write` persists them through the Effect `FileSystem`.
|
||||
const generateImage = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: openai.image("gpt-image-1-mini"),
|
||||
prompt: "A flat black circle centered on a plain white background.",
|
||||
size: "1024x1024",
|
||||
format: "jpeg",
|
||||
providerOptions: { quality: "low" },
|
||||
})
|
||||
|
||||
console.log("\n== image ==")
|
||||
console.log("media type:", response.image.mediaType)
|
||||
console.log("bytes:", (yield* response.image.bytes()).byteLength)
|
||||
console.log("usage", Formatter.formatJson(response.usage, { space: 2 }))
|
||||
yield* Media.write(response.image, "tutorial-image.jpg").pipe(Effect.provide(NodeFileSystem.layer))
|
||||
})
|
||||
|
||||
// Provide the LLM runtime and the HTTP request executor once. Keep one path
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
const imageClientLayer = ImageClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
// yield* generateOnce
|
||||
// yield* streamText
|
||||
// yield* generateStructuredObject
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
// yield* generateImage
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer, imageClientLayer)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||
|
||||
Effect.runPromise(program)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.15",
|
||||
"name": "@opencode/ai",
|
||||
"version": "1.17.20",
|
||||
"name": "@opencode-ai/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -15,14 +15,13 @@
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./promise": "./src/promise.ts",
|
||||
"./testing": "./src/testing.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode/http-recorder": "workspace:*",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -32,7 +31,7 @@
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"aws4fetch": "1.0.20",
|
||||
"effect": "catalog:",
|
||||
"google-auth-library": "10.5.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Script } from "@opencode/script"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ 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"
|
||||
import { CloudflareAIGateway } from "../src/providers/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAI } from "../src/providers/cloudflare-workers-ai.js"
|
||||
import * as Cloudflare from "../src/providers/cloudflare"
|
||||
|
||||
type Provider = {
|
||||
readonly id: string
|
||||
@@ -104,83 +103,6 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
vars: [{ name: "XAI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "fal",
|
||||
label: "fal",
|
||||
tier: "canary",
|
||||
note: "fal queue video recorded tests",
|
||||
vars: [{ name: "FAL_KEY" }],
|
||||
// fal has no free authenticated list endpoint; a 404 for an unknown request id proves the key was accepted.
|
||||
validate: (env) =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.get(
|
||||
"https://queue.fal.run/fal-ai/veo3.1/requests/00000000-0000-0000-0000-000000000000/status",
|
||||
).pipe(HttpClientRequest.setHeaders({ authorization: `Key ${Redacted.value(Redacted.make(env.FAL_KEY))}` })),
|
||||
)
|
||||
if (response.status === 404) return undefined
|
||||
return yield* responseError(response)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "runway",
|
||||
label: "Runway",
|
||||
tier: "canary",
|
||||
note: "Runway task video recorded tests",
|
||||
vars: [{ name: "RUNWAYML_API_SECRET" }],
|
||||
validate: (env) =>
|
||||
validateBearer("https://api.dev.runwayml.com/v1/organization", Redacted.make(env.RUNWAYML_API_SECRET), {
|
||||
"X-Runway-Version": "2024-11-06",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "elevenlabs",
|
||||
label: "ElevenLabs",
|
||||
tier: "canary",
|
||||
note: "ElevenLabs text-to-speech recorded tests",
|
||||
vars: [{ name: "ELEVENLABS_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.elevenlabs.io/v1/models").pipe(
|
||||
HttpClientRequest.setHeader("xi-api-key", Redacted.value(Redacted.make(env.ELEVENLABS_API_KEY))),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cartesia",
|
||||
label: "Cartesia",
|
||||
tier: "canary",
|
||||
note: "Cartesia text-to-speech recorded tests",
|
||||
vars: [{ name: "CARTESIA_API_KEY" }],
|
||||
validate: (env) =>
|
||||
validateBearer("https://api.cartesia.ai/voices?limit=1", Redacted.make(env.CARTESIA_API_KEY), {
|
||||
"Cartesia-Version": "2026-08-14",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "deepgram",
|
||||
label: "Deepgram",
|
||||
tier: "canary",
|
||||
note: "Deepgram Aura text-to-speech and Nova transcription recorded tests",
|
||||
vars: [{ name: "DEEPGRAM_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.deepgram.com/v1/projects").pipe(
|
||||
HttpClientRequest.setHeader("authorization", `Token ${Redacted.value(Redacted.make(env.DEEPGRAM_API_KEY))}`),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "assemblyai",
|
||||
label: "AssemblyAI",
|
||||
tier: "canary",
|
||||
note: "AssemblyAI queued transcription recorded tests",
|
||||
vars: [{ name: "ASSEMBLYAI_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.assemblyai.com/v2/transcript?limit=1").pipe(
|
||||
HttpClientRequest.setHeader("authorization", Redacted.value(Redacted.make(env.ASSEMBLYAI_API_KEY))),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cloudflare-ai-gateway",
|
||||
label: "Cloudflare AI Gateway",
|
||||
@@ -198,11 +120,11 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${CloudflareAIGateway.baseURL({
|
||||
url: `${Cloudflare.aiGatewayBaseURL({
|
||||
accountId: env.CLOUDFLARE_ACCOUNT_ID,
|
||||
gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined,
|
||||
})}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, CloudflareAIGateway.authEnvVars)),
|
||||
token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)),
|
||||
tokenHeader: "cf-aig-authorization",
|
||||
model: "workers-ai/@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
@@ -218,8 +140,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${CloudflareWorkersAI.baseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, CloudflareWorkersAI.authEnvVars)),
|
||||
url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)),
|
||||
model: "@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
|
||||
import { effortUpdate } from "./effort-updates.js"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
@@ -122,15 +121,9 @@ const markMessages = (
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
|
||||
if (strategy === "latest-assistant")
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
|
||||
let start = messages.length
|
||||
let remaining = strategy.tail
|
||||
while (remaining > 0 && start > 0) {
|
||||
start -= 1
|
||||
if (effortUpdate(messages[start]!) === undefined) remaining -= 1
|
||||
}
|
||||
const start = Math.max(0, messages.length - strategy.tail)
|
||||
let next = messages
|
||||
for (let i = start; i < messages.length; i++)
|
||||
if (effortUpdate(messages[i]!) === undefined) next = markMessageAt(next, i, hint, budget)
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Changing the top-level reasoning effort invalidates the provider prompt cache. Protocols with a native
|
||||
// per-message update keep it frozen and lower `Message.effort(...)` markers instead; other routes strip them.
|
||||
import { LLMRequest, type EffortPart, type Message } from "./schema/messages.js"
|
||||
|
||||
export const effortUpdate = (message: Message): EffortPart | undefined => {
|
||||
if (message.role !== "system" || message.content.length !== 1) return undefined
|
||||
const part = message.content[0]
|
||||
return part.type === "effort" ? part : undefined
|
||||
}
|
||||
|
||||
export const stripEffortUpdates = (request: LLMRequest) => {
|
||||
const messages = request.messages.filter((message) => effortUpdate(message) === undefined)
|
||||
return messages.length === request.messages.length ? request : LLMRequest.update(request, { messages })
|
||||
}
|
||||
|
||||
export const applyEffortUpdates = (request: LLMRequest): LLMRequest =>
|
||||
request.model.route.supportsEffortUpdates?.(request) ? request : stripEffortUpdates(request)
|
||||
|
||||
// Reverted or forked history can leave the last marker disagreeing with the requested effort.
|
||||
export const resolveEffortUpdates = (request: LLMRequest, current: string | undefined) => {
|
||||
const updates = request.messages.flatMap((message) => effortUpdate(message) ?? [])
|
||||
if (updates.length === 0) return { request, effort: current }
|
||||
if (updates.at(-1)?.effort !== current) return { request: stripEffortUpdates(request), effort: current }
|
||||
return { request, effort: updates[0]?.previous }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export { EvaluationClient } from "./experimental/evaluation-client.js"
|
||||
export {
|
||||
BooleanAnswer,
|
||||
BooleanQuestion,
|
||||
ChoiceAnswer,
|
||||
ChoiceQuestion,
|
||||
Evaluation,
|
||||
EvaluationAnswer,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationModelSchema,
|
||||
EvaluationQuestion,
|
||||
EvaluationRequest,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreAnswer,
|
||||
ScoreQuestion,
|
||||
} from "./experimental/evaluation.js"
|
||||
export type {
|
||||
AnswerFor,
|
||||
AnswersFor,
|
||||
EvaluationModelOptions,
|
||||
EvaluationOptions,
|
||||
EvaluationQuestions,
|
||||
EvaluationRequestFor,
|
||||
EvaluationRequestInput,
|
||||
EvaluationResponseFor,
|
||||
EvaluationRoute,
|
||||
} from "./experimental/evaluation.js"
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "../route/executor.js"
|
||||
import { AIError, InvalidProviderOutputError, mergeHttpOptions } from "../schema/index.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import {
|
||||
type EvaluationOptions,
|
||||
type EvaluationQuestions,
|
||||
type EvaluationRequestFor,
|
||||
type EvaluationResponseFor,
|
||||
} from "./evaluation.js"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
export interface Interface {
|
||||
readonly evaluate: <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/Experimental/EvaluationClient") {}
|
||||
|
||||
export const evaluate = <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service> =>
|
||||
Effect.flatMap(Service, (client) => client.evaluate(request))
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
evaluate: (request) =>
|
||||
request.model.route
|
||||
.evaluate(
|
||||
{
|
||||
...sanitizeSurrogates({
|
||||
...request,
|
||||
model: undefined,
|
||||
http: mergeHttpOptions(request.model.http, request.http),
|
||||
}),
|
||||
model: request.model,
|
||||
},
|
||||
executor.execute,
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((response) => {
|
||||
const questions = Object.entries(request.questions)
|
||||
if (
|
||||
questions.length === Object.keys(response.answers).length &&
|
||||
questions.every(([id, question]) => {
|
||||
const answer = response.answers[id]
|
||||
if (question.type === "boolean") return answer?.type === "boolean"
|
||||
if (question.type === "choice") {
|
||||
if (answer?.type !== "choice" || !Object.hasOwn(question.criteria, answer.choice)) return false
|
||||
if (answer.probabilities === undefined) return true
|
||||
const keys = Object.keys(question.criteria)
|
||||
const probabilities = answer.probabilities
|
||||
return (
|
||||
Object.keys(probabilities).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(probabilities, key))
|
||||
)
|
||||
}
|
||||
if (answer?.type !== "score" || answer.score < 0 || answer.score > question.criteria.length - 1)
|
||||
return false
|
||||
if (answer.probabilities === undefined) return true
|
||||
const keys = question.criteria.map((_, index) => String(index))
|
||||
const probabilities = answer.probabilities
|
||||
return (
|
||||
Object.keys(probabilities).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(probabilities, key))
|
||||
)
|
||||
})
|
||||
)
|
||||
return Effect.succeed(response as EvaluationResponseFor<typeof request.questions>)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route: request.model.route.id,
|
||||
message: "Evaluation answers do not match the requested questions",
|
||||
cause: response.answers,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
export const EvaluationClient = {
|
||||
Service,
|
||||
layer,
|
||||
fetchLayer,
|
||||
evaluate,
|
||||
} as const
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
Usage,
|
||||
} from "../schema/index.js"
|
||||
import { EvaluationClient, Service, type Execute } from "./evaluation-client.js"
|
||||
|
||||
export const EvaluationInput = Schema.Union([Schema.String, Schema.JsonObject, Schema.Array(Schema.Json)])
|
||||
export type EvaluationInput = Schema.Schema.Type<typeof EvaluationInput>
|
||||
|
||||
const EvaluationCriterion = Schema.NullOr(EvaluationInput)
|
||||
const ChoiceCriteria = Schema.Record(Schema.String, EvaluationCriterion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Choice criteria must be a nonempty option map",
|
||||
}),
|
||||
)
|
||||
|
||||
export const ChoiceQuestion = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: ChoiceCriteria,
|
||||
})
|
||||
export type ChoiceQuestion = Schema.Schema.Type<typeof ChoiceQuestion>
|
||||
|
||||
export const ScoreQuestion = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.Array(EvaluationCriterion).check(Schema.isMinLength(2)),
|
||||
})
|
||||
export type ScoreQuestion = Schema.Schema.Type<typeof ScoreQuestion>
|
||||
|
||||
export const BooleanQuestion = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(EvaluationCriterion),
|
||||
false: Schema.optional(EvaluationCriterion),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type BooleanQuestion = Schema.Schema.Type<typeof BooleanQuestion>
|
||||
|
||||
export const EvaluationQuestion = Schema.Union([ChoiceQuestion, ScoreQuestion, BooleanQuestion]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationQuestion = Schema.Schema.Type<typeof EvaluationQuestion>
|
||||
export type EvaluationQuestions = Readonly<Record<string, EvaluationQuestion>>
|
||||
const EvaluationQuestions = Schema.Record(Schema.String, EvaluationQuestion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Evaluation questions must be a nonempty map",
|
||||
}),
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
|
||||
export const ChoiceAnswer = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
})
|
||||
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
|
||||
|
||||
export const ScoreAnswer = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
})
|
||||
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
|
||||
|
||||
export const BooleanAnswer = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
probability: Probability,
|
||||
})
|
||||
export type BooleanAnswer = Schema.Schema.Type<typeof BooleanAnswer>
|
||||
|
||||
export const EvaluationAnswer = Schema.Union([ChoiceAnswer, ScoreAnswer, BooleanAnswer]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationAnswer = Schema.Schema.Type<typeof EvaluationAnswer>
|
||||
|
||||
export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
|
||||
readonly type: "choice"
|
||||
readonly criteria: infer Criteria
|
||||
}
|
||||
? {
|
||||
readonly type: "choice"
|
||||
readonly choice: Extract<keyof Criteria, string>
|
||||
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
|
||||
}
|
||||
: Question extends { readonly type: "score" }
|
||||
? ScoreAnswer
|
||||
: BooleanAnswer
|
||||
|
||||
export type AnswersFor<Questions extends EvaluationQuestions> = {
|
||||
readonly [ID in keyof Questions]: AnswerFor<Questions[ID]>
|
||||
}
|
||||
|
||||
export type EvaluationOptions = Record<string, unknown>
|
||||
|
||||
export interface EvaluationRoute<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: string
|
||||
readonly evaluate: (
|
||||
request: EvaluationRequestFor<Options>,
|
||||
execute: Execute,
|
||||
) => Effect.Effect<EvaluationResponse, AIError>
|
||||
}
|
||||
|
||||
export class EvaluationModel<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
|
||||
constructor(input: EvaluationModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
|
||||
static make<Options extends EvaluationOptions = EvaluationOptions>(input: EvaluationModel.MakeInput<Options>) {
|
||||
return new EvaluationModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace EvaluationModel {
|
||||
export interface Input<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends EvaluationOptions = EvaluationOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export const EvaluationModelSchema = Schema.declare(
|
||||
(value): value is EvaluationModel => value instanceof EvaluationModel,
|
||||
{
|
||||
expected: "Evaluation.Model",
|
||||
},
|
||||
)
|
||||
|
||||
export class EvaluationRequest extends Schema.Class<EvaluationRequest>("Evaluation.Request")({
|
||||
model: EvaluationModelSchema,
|
||||
state: EvaluationInput,
|
||||
questions: EvaluationQuestions,
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _EvaluationRequest: void
|
||||
}
|
||||
|
||||
export type EvaluationModelOptions<Model> = Model extends EvaluationModel<infer Options> ? Options : never
|
||||
|
||||
export type EvaluationRequestFor<
|
||||
Options extends EvaluationOptions = EvaluationOptions,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<EvaluationRequest, "model" | "questions" | "options"> & {
|
||||
readonly model: EvaluationModel<Options>
|
||||
readonly questions: Questions
|
||||
readonly options?: Options
|
||||
}
|
||||
|
||||
export type EvaluationRequestInput<
|
||||
Model extends object = EvaluationModel,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<ConstructorParameters<typeof EvaluationRequest>[0], "model" | "questions" | "options" | "http"> & {
|
||||
readonly model: Model
|
||||
readonly questions: Questions
|
||||
readonly options?: NoInfer<EvaluationModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
} & (Model extends EvaluationModel<EvaluationModelOptions<Model>> ? unknown : never)
|
||||
|
||||
export class EvaluationRounding extends Schema.Class<EvaluationRounding>("Evaluation.Rounding")({
|
||||
probabilityDecimals: Schema.optional(Schema.Int),
|
||||
scoreDecimals: Schema.optional(Schema.Int),
|
||||
}) {}
|
||||
|
||||
export class EvaluationResponse extends Schema.Class<EvaluationResponse>("Evaluation.Response")({
|
||||
model: ModelID,
|
||||
answers: Schema.Record(Schema.String, EvaluationAnswer),
|
||||
usage: Schema.optional(Usage),
|
||||
rounding: Schema.optional(EvaluationRounding),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export type EvaluationResponseFor<Questions extends EvaluationQuestions> = Omit<EvaluationResponse, "answers"> & {
|
||||
readonly answers: AnswersFor<Questions>
|
||||
}
|
||||
|
||||
export function request<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): EvaluationRequestFor<EvaluationModelOptions<Model>, Questions>
|
||||
export function request(input: EvaluationRequest): EvaluationRequest
|
||||
export function request(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
if (input instanceof EvaluationRequest) return input
|
||||
return new EvaluationRequest({
|
||||
...input,
|
||||
model: input.model as unknown as EvaluationModel,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
export function run<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>
|
||||
export function run(input: EvaluationRequest): Effect.Effect<EvaluationResponse, AIError, Service>
|
||||
export function run(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
return Effect.try({
|
||||
try: () => (input instanceof EvaluationRequest ? input : request(input)),
|
||||
catch: (cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.flatMap((request) =>
|
||||
EvaluationClient.evaluate(request as EvaluationRequestFor<EvaluationOptions, EvaluationQuestions>),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const Evaluation = {
|
||||
request,
|
||||
run,
|
||||
} as const
|
||||
@@ -1,194 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
ChoiceQuestion,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreQuestion,
|
||||
type EvaluationAnswer,
|
||||
type EvaluationOptions,
|
||||
} from "./evaluation.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpContext,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
Usage,
|
||||
mergeJsonRecords,
|
||||
} from "../schema/index.js"
|
||||
|
||||
const Noul = Schema.Struct({
|
||||
type: Schema.Literal("noul"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
false: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const Question = Schema.Union([
|
||||
ChoiceQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x.criteria).length <= 255, {
|
||||
message: "System One Choice questions support at most 255 options",
|
||||
}),
|
||||
),
|
||||
ScoreQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => x.criteria.length <= 10, {
|
||||
message: "System One Score questions support at most 10 levels",
|
||||
}),
|
||||
),
|
||||
Noul,
|
||||
])
|
||||
const Request = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
model: Schema.String,
|
||||
state: EvaluationInput,
|
||||
questions: Schema.Record(Schema.String, Question),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
const NoulAnswer = Schema.Struct({ type: Schema.Literal("noul"), noul: Probability })
|
||||
const Choice = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const Score = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
legend: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const Answer = Schema.Union([NoulAnswer, Choice, Score]).pipe(Schema.toTaggedUnion("type"))
|
||||
const NativeUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const Response = Schema.Struct({
|
||||
model: Schema.String,
|
||||
answers: Schema.Record(Schema.String, Answer),
|
||||
usage: Schema.optional(NativeUsage),
|
||||
id: Schema.optional(Schema.String),
|
||||
provider: Schema.optional(Schema.String),
|
||||
provider_metadata: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
|
||||
})
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg: ModelInput) =>
|
||||
EvaluationModel.make<Options>({
|
||||
id: cfg.id,
|
||||
provider: cfg.provider,
|
||||
http: cfg.http,
|
||||
route: {
|
||||
id: "system-one",
|
||||
evaluate: (req, send) =>
|
||||
Effect.gen(function* () {
|
||||
const url = new URL(`${cfg.baseURL.replace(/\/$/, "")}/systemone`)
|
||||
Object.entries(req.http?.query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value))
|
||||
const body = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(Request))({
|
||||
...mergeJsonRecords(req.options, req.http?.body),
|
||||
model: req.model.id,
|
||||
state: req.state,
|
||||
questions: Object.fromEntries(
|
||||
Object.entries(req.questions).map(([id, x]) => [id, x.type === "boolean" ? { ...x, type: "noul" } : x]),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new AIError({ reason: new InvalidRequestError({ message: cause.message, cause }) }),
|
||||
),
|
||||
)
|
||||
const headers = yield* Auth.toEffect(cfg.auth)({
|
||||
request: req,
|
||||
method: "POST",
|
||||
url: url.toString(),
|
||||
body,
|
||||
headers: Headers.fromInput({ ...cfg.headers, ...req.http?.headers }),
|
||||
})
|
||||
const res = yield* send(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(body, "application/json"),
|
||||
),
|
||||
)
|
||||
const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers })
|
||||
const fail = (message: string, cause: unknown, body?: string) =>
|
||||
new AIError({ reason: new InvalidProviderOutputError({ route: "system-one", message, body, http, cause }) })
|
||||
const text = yield* res.text.pipe(
|
||||
Effect.mapError((cause) => fail("Failed to read the System One response", cause)),
|
||||
)
|
||||
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
|
||||
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
|
||||
)
|
||||
|
||||
const confidence: Record<string, number> = {}
|
||||
const legend: Record<string, Record<string, Schema.Json>> = {}
|
||||
const answers = Object.fromEntries(
|
||||
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
|
||||
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
|
||||
if (answer.type === "choice") {
|
||||
if (answer.confidence !== undefined) confidence[id] = answer.confidence
|
||||
return [
|
||||
id,
|
||||
{
|
||||
type: "choice",
|
||||
choice: answer.choice,
|
||||
probabilities: answer.probabilities,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (answer.confidence !== undefined) confidence[id] = answer.confidence
|
||||
if (answer.legend !== undefined) legend[id] = answer.legend
|
||||
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
|
||||
}),
|
||||
)
|
||||
const meta = {
|
||||
...(data.id === undefined ? {} : { responseId: data.id }),
|
||||
...(data.provider === undefined ? {} : { provider: data.provider }),
|
||||
...data.provider_metadata?.[cfg.providerMetadataKey],
|
||||
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
|
||||
...(Object.keys(legend).length === 0 ? {} : { legend }),
|
||||
}
|
||||
return new EvaluationResponse({
|
||||
model: ModelID.make(data.model),
|
||||
answers,
|
||||
usage: data.usage
|
||||
? new Usage({
|
||||
inputTokens: data.usage.input_tokens,
|
||||
outputTokens: data.usage.output_tokens,
|
||||
totalTokens:
|
||||
data.usage.input_tokens === undefined && data.usage.output_tokens === undefined
|
||||
? undefined
|
||||
: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0),
|
||||
providerMetadata: { [cfg.providerMetadataKey]: data.usage },
|
||||
})
|
||||
: undefined,
|
||||
rounding: new EvaluationRounding({ probabilityDecimals: 2, scoreDecimals: 2 }),
|
||||
providerMetadata: Object.keys(meta).length === 0 ? undefined : { [cfg.providerMetadataKey]: meta },
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const SystemOne = { model } as const
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Clock, Duration, Effect, Schedule, Schema, Stream } from "effect"
|
||||
import { AIError, TimeoutError } from "./schema/errors.js"
|
||||
|
||||
export const Status = Schema.Literals(["queued", "running", "completed", "failed", "cancelled", "expired"])
|
||||
export type Status = Schema.Schema.Type<typeof Status>
|
||||
|
||||
/** Provider-neutral view of one generation observation. */
|
||||
export interface Snapshot {
|
||||
readonly id: string
|
||||
readonly status: Status
|
||||
/** Normalized 0..1 when the provider reports progress. */
|
||||
readonly progress?: number
|
||||
readonly position?: number
|
||||
readonly expiresAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-owned generation operations for one generation. The media route decodes its serializable token once (from the
|
||||
* submission response or a `resume` input) and closes over it, so `Generation` never sees the token's shape.
|
||||
*/
|
||||
export interface Route<Response> {
|
||||
readonly status: Effect.Effect<Snapshot, AIError>
|
||||
readonly result: Effect.Effect<Response, AIError>
|
||||
readonly cancel?: Effect.Effect<void, AIError>
|
||||
/** Provider polling hint (e.g. `openai-poll-after-ms`) that overrides the default interval for the next poll. */
|
||||
readonly pollHint?: (snapshot: Snapshot) => Duration.Duration | undefined
|
||||
}
|
||||
|
||||
export interface Poll {
|
||||
readonly interval?: Duration.Input
|
||||
readonly timeout?: Duration.Input
|
||||
/** Full override of the polling schedule; `interval` and `pollHint` are ignored when supplied. */
|
||||
readonly schedule?: Schedule.Schedule<unknown, Snapshot>
|
||||
}
|
||||
|
||||
export interface AwaitOptions {
|
||||
readonly poll?: Poll
|
||||
}
|
||||
|
||||
export const DEFAULT_POLL_INTERVAL = Duration.seconds(5)
|
||||
export const DEFAULT_POLL_TIMEOUT = Duration.minutes(10)
|
||||
|
||||
export const QueuedEvent = Schema.Struct({
|
||||
type: Schema.tag("generation-queued"),
|
||||
id: Schema.String,
|
||||
position: Schema.optional(Schema.Number),
|
||||
}).annotate({ identifier: "Generation.Event.Queued" })
|
||||
|
||||
export const ProgressEvent = Schema.Struct({
|
||||
type: Schema.tag("generation-progress"),
|
||||
id: Schema.String,
|
||||
progress: Schema.optional(Schema.Number),
|
||||
}).annotate({ identifier: "Generation.Event.Progress" })
|
||||
|
||||
export type Observation = Schema.Schema.Type<typeof QueuedEvent> | Schema.Schema.Type<typeof ProgressEvent>
|
||||
|
||||
export type Event = Observation | { readonly type: "generation-finished"; readonly id: string; readonly status: Status }
|
||||
|
||||
const TERMINAL: ReadonlySet<Status> = new Set(["completed", "failed", "cancelled", "expired"])
|
||||
|
||||
export class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly status: Status
|
||||
readonly progress?: number
|
||||
readonly position?: number
|
||||
readonly expiresAt?: number
|
||||
|
||||
constructor(
|
||||
readonly route: Route<Response>,
|
||||
/** Route-owned serializable JSON; pass it to the modality's `resume` from another process. */
|
||||
readonly token: unknown,
|
||||
snapshot: Snapshot,
|
||||
) {
|
||||
this.id = snapshot.id
|
||||
this.status = snapshot.status
|
||||
this.progress = snapshot.progress
|
||||
this.position = snapshot.position
|
||||
this.expiresAt = snapshot.expiresAt
|
||||
}
|
||||
|
||||
get snapshot(): Snapshot {
|
||||
return {
|
||||
id: this.id,
|
||||
status: this.status,
|
||||
progress: this.progress,
|
||||
position: this.position,
|
||||
expiresAt: this.expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
get terminal() {
|
||||
return TERMINAL.has(this.status)
|
||||
}
|
||||
|
||||
refresh(): Effect.Effect<Generation<Response>, AIError> {
|
||||
return this.route.status.pipe(Effect.map((snapshot) => new Generation(this.route, this.token, snapshot)))
|
||||
}
|
||||
|
||||
/** Fetch the result without polling; non-completed terminal generations fail with the provider's terminal body. */
|
||||
result(): Effect.Effect<Response, AIError> {
|
||||
return this.route.result
|
||||
}
|
||||
|
||||
/** Poll until the generation reaches a terminal status, then fetch the result. Fails with a `Timeout` reason on deadline. */
|
||||
await(options?: AwaitOptions): Effect.Effect<Response, AIError> {
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
const settled = this.terminal ? Effect.succeed(this) : this.poll(options?.poll)
|
||||
return settled.pipe(
|
||||
// Non-completed terminal states also go through `result` so the route can surface its provider failure body.
|
||||
Effect.flatMap((generation) => generation.result()),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => this.timeoutError(timeout) }),
|
||||
)
|
||||
}
|
||||
|
||||
cancel(): Effect.Effect<void, AIError> {
|
||||
return this.route.cancel ?? Effect.void
|
||||
}
|
||||
|
||||
/**
|
||||
* Status observations as a stream, ending after the first terminal observation. Each poll is bounded by the time
|
||||
* remaining until `poll.timeout`, so a hung status request fails the stream instead of stalling it. (`Stream.interruptWhen`
|
||||
* would express this directly but deadlocks under `TestClock` when the source completes while the timer sleeps.)
|
||||
*/
|
||||
events(options?: AwaitOptions): Stream.Stream<Event, AIError> {
|
||||
if (this.terminal) return Stream.make(this.event())
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
return Stream.unwrap(
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) => {
|
||||
const deadline = start + Duration.toMillis(timeout)
|
||||
const refresh = Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
this.refresh().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(Math.max(0, deadline - now)),
|
||||
orElse: () => this.timeoutError(timeout),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Stream.fromEffectSchedule(refresh, this.schedule(options?.poll)).pipe(
|
||||
Stream.takeUntil((generation) => generation.terminal),
|
||||
Stream.map((generation) => generation.event()),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private event(): Event {
|
||||
if (this.terminal) return { type: "generation-finished", id: this.id, status: this.status }
|
||||
if (this.status === "queued") return { type: "generation-queued", id: this.id, position: this.position }
|
||||
return { type: "generation-progress", id: this.id, progress: this.progress }
|
||||
}
|
||||
|
||||
private timeoutError(timeout: Duration.Duration) {
|
||||
return new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${this.id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
private poll(poll: Poll | undefined) {
|
||||
return this.refresh().pipe(
|
||||
Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }),
|
||||
)
|
||||
}
|
||||
|
||||
private schedule(poll: Poll | undefined): Schedule.Schedule<unknown, Generation<Response>> {
|
||||
if (poll?.schedule) return poll.schedule.pipe(Schedule.setInputType<Generation<Response>>())
|
||||
const interval = poll?.interval ?? DEFAULT_POLL_INTERVAL
|
||||
const pollHint = this.route.pollHint
|
||||
const spaced = Schedule.spaced(interval).pipe(Schedule.setInputType<Generation<Response>>())
|
||||
if (!pollHint) return spaced
|
||||
return spaced.pipe(
|
||||
Schedule.modifyDelay((metadata) => Effect.succeed(pollHint(metadata.input.snapshot) ?? interval)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const resultEvents = <Response, A>(
|
||||
generation: Generation<Response>,
|
||||
expand: (response: Response) => ReadonlyArray<A>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<Observation | A, AIError> =>
|
||||
generation.events(options).pipe(
|
||||
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
|
||||
Stream.concat(Stream.fromIterableEffect(Effect.map(generation.result(), expand))),
|
||||
)
|
||||
@@ -1,21 +1,15 @@
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import type { AIError } from "./schema/index.js"
|
||||
import {
|
||||
responseEvents,
|
||||
type ImageEvent,
|
||||
type ImageOptions,
|
||||
type ImageRequestFor,
|
||||
type ImageResponse,
|
||||
} from "./image.js"
|
||||
import { mergeHttpOptions, type AIError } from "./schema/index.js"
|
||||
import { sanitizeSurrogates } from "./utils/sanitize.js"
|
||||
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
export interface Interface {
|
||||
readonly generate: <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
) => Effect.Effect<ImageResponse, AIError>
|
||||
readonly stream: <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
) => Stream.Stream<ImageEvent, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||
@@ -28,27 +22,23 @@ export const generate = <Options extends ImageOptions>(
|
||||
return yield* client.generate(request)
|
||||
})
|
||||
|
||||
export const stream = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
): Stream.Stream<ImageEvent, AIError, Service> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return client.stream(request)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const generate = <Options extends ImageOptions>(request: ImageRequestFor<Options>) =>
|
||||
request.model.route.generate(request, executor.execute)
|
||||
return Service.of({
|
||||
generate,
|
||||
// Inline routes have no partial frames yet; the stream is the completed response expanded into events.
|
||||
stream: (request) =>
|
||||
Stream.fromIterableEffect(Effect.map(generate(request), responseEvents)),
|
||||
generate: (request) =>
|
||||
request.model.route.generate(
|
||||
{
|
||||
...sanitizeSurrogates({
|
||||
...request,
|
||||
model: undefined,
|
||||
http: mergeHttpOptions(request.model.http, request.http),
|
||||
}),
|
||||
model: request.model,
|
||||
},
|
||||
executor.execute,
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -57,5 +47,4 @@ export const ImageClient = {
|
||||
Service,
|
||||
layer,
|
||||
generate,
|
||||
stream,
|
||||
} as const
|
||||
|
||||
+101
-119
@@ -1,115 +1,134 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Media } from "./media.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
|
||||
import { ImageClient, Service } from "./image-client.js"
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
HttpOptions,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
Usage,
|
||||
} from "./schema/index.js"
|
||||
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: string
|
||||
readonly generate: (request: ImageRequestFor<Options>, execute: ImageExecute) => Effect.Effect<ImageResponse, AIError>
|
||||
}
|
||||
|
||||
export type ImageOptions = Record<string, unknown>
|
||||
|
||||
export type ImageRoute<Options extends ImageOptions = ImageOptions> = MediaRoute.Route<
|
||||
ImageRequestFor<Options>,
|
||||
ImageResponse
|
||||
>
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute<Options>, Options> {
|
||||
declare protected readonly _ImageModel: void
|
||||
|
||||
static make<Options extends ImageOptions = ImageOptions>(input: MediaModel.Input<ImageRoute<Options>>) {
|
||||
return new ImageModel<Options>(input)
|
||||
constructor(input: ImageModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
|
||||
/** Compose an inline image protocol with its canonical path into a model for one deployment. */
|
||||
static fromRoute<Options extends ImageOptions = ImageOptions>(
|
||||
route: ImageModel.RouteInput<Options>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||
return new ImageModel<Options>({
|
||||
id: input.id,
|
||||
provider: route.provider,
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
http: input.http,
|
||||
route: composeRoute(MediaRoute.inline, route, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ImageModel {
|
||||
export type RouteInput<Options extends ImageOptions = ImageOptions> = MediaModel.RouteInput<
|
||||
ImageRequestFor<Options>,
|
||||
MediaProtocol.Inline<ImageRequestFor<Options>, ImageResponse>
|
||||
>
|
||||
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||
expected: "Image.Model",
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request
|
||||
// ---------------------------------------------------------------------------
|
||||
const ImageBytesInput = Schema.Struct({
|
||||
type: Schema.Literal("bytes"),
|
||||
data: Schema.Uint8Array,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
const ImageUrlInput = Schema.Struct({
|
||||
type: Schema.Literal("url"),
|
||||
url: Schema.String,
|
||||
})
|
||||
const ImageFileIDInput = Schema.Struct({
|
||||
type: Schema.Literal("file-id"),
|
||||
id: Schema.String,
|
||||
})
|
||||
const ImageFileURIInput = Schema.Struct({
|
||||
type: Schema.Literal("file-uri"),
|
||||
uri: Schema.String,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
|
||||
export type ImageSize = `${number}x${number}`
|
||||
export const ImageSize = Schema.declare<ImageSize>(
|
||||
(value): value is ImageSize => typeof value === "string" && /^\d+x\d+$/.test(value),
|
||||
{ title: "ImageSize" },
|
||||
)
|
||||
export const ImageInputSchema = Schema.Union([
|
||||
ImageBytesInput,
|
||||
ImageUrlInput,
|
||||
ImageFileIDInput,
|
||||
ImageFileURIInput,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
|
||||
|
||||
export type ImageAspectRatio = Media.AspectRatio
|
||||
export const ImageAspectRatio = Media.AspectRatio
|
||||
|
||||
export type ImageFormat = "png" | "jpeg" | "webp" | (string & {})
|
||||
export const ImageInput = {
|
||||
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
|
||||
url: (url: string): ImageInput => ({ type: "url", url }),
|
||||
file: (id: string): ImageInput => ({ type: "file-id", id }),
|
||||
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
|
||||
} as const
|
||||
|
||||
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||
model: ImageModelSchema,
|
||||
prompt: Schema.String,
|
||||
/** Edit sources or style/subject references, in order. */
|
||||
images: Schema.optional(Schema.Array(Media.AssetSchema)),
|
||||
/** Inpainting mask; routes that cannot honor it fail with `UnsupportedOperation`. */
|
||||
mask: Schema.optional(Media.AssetSchema),
|
||||
n: Schema.optional(Schema.Int),
|
||||
size: Schema.optional(ImageSize),
|
||||
aspectRatio: Schema.optional(ImageAspectRatio),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
format: Schema.optional(Schema.String),
|
||||
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
images: Schema.optional(Schema.Array(ImageInputSchema)),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _ImageRequest: void
|
||||
}
|
||||
|
||||
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<
|
||||
ImageRequest,
|
||||
"model" | "providerOptions"
|
||||
> & {
|
||||
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
|
||||
readonly model: ImageModel<Options>
|
||||
readonly providerOptions?: Options
|
||||
readonly options?: Options
|
||||
}
|
||||
|
||||
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
|
||||
|
||||
export type ImageRequestInput<Model extends ImageModel = ImageModel> = Omit<
|
||||
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
|
||||
ConstructorParameters<typeof ImageRequest>[0],
|
||||
"model" | "providerOptions" | "http"
|
||||
"model" | "options" | "http"
|
||||
> & {
|
||||
readonly model: Model
|
||||
readonly format?: ImageFormat
|
||||
readonly providerOptions?: NoInfer<ImageModelOptions<Model>>
|
||||
readonly options?: NoInfer<ImageModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response and events
|
||||
// ---------------------------------------------------------------------------
|
||||
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||
mediaType: Schema.String,
|
||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
|
||||
images: Schema.Array(Media.AssetSchema),
|
||||
usage: Schema.optional(MediaUsage),
|
||||
notices: Schema.optional(Schema.Array(Media.Notice)),
|
||||
images: Schema.Array(GeneratedImage),
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
get image() {
|
||||
@@ -117,43 +136,7 @@ export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageOutputEvent = Schema.Struct({
|
||||
type: Schema.tag("image"),
|
||||
index: Schema.Number,
|
||||
image: Media.AssetSchema,
|
||||
}).annotate({ identifier: "Image.Event.Image" })
|
||||
|
||||
export const ImageFinishEvent = Schema.Struct({
|
||||
type: Schema.tag("finish"),
|
||||
usage: Schema.optional(MediaUsage),
|
||||
notices: Schema.optional(Schema.Array(Media.Notice)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "Image.Event.Finish" })
|
||||
|
||||
const imageEventTagged = Schema.Union([ImageOutputEvent, ImageFinishEvent]).pipe(Schema.toTaggedUnion("type"))
|
||||
export const ImageEvent = Object.assign(imageEventTagged, {
|
||||
is: {
|
||||
image: imageEventTagged.guards.image,
|
||||
finish: imageEventTagged.guards.finish,
|
||||
},
|
||||
})
|
||||
export type ImageEvent = Schema.Schema.Type<typeof imageEventTagged>
|
||||
|
||||
/** Inline routes produce every image at once; expand the response into the streaming event shape. */
|
||||
export const responseEvents = (response: ImageResponse): ReadonlyArray<ImageEvent> => [
|
||||
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
|
||||
ImageFinishEvent.make({
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request-shaped call API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function request<const Model extends ImageModel>(
|
||||
export function request<const Model extends object>(
|
||||
input: ImageRequestInput<Model>,
|
||||
): ImageRequestFor<ImageModelOptions<Model>>
|
||||
export function request(input: ImageRequest): ImageRequest
|
||||
@@ -161,30 +144,29 @@ export function request(input: ImageRequest | ImageRequestInput) {
|
||||
if (input instanceof ImageRequest) return input
|
||||
return new ImageRequest({
|
||||
...input,
|
||||
model: input.model as unknown as ImageModel,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function generate<const Model extends ImageModel>(
|
||||
export function generate<const Model extends object>(
|
||||
input: ImageRequestInput<Model>,
|
||||
): Effect.Effect<ImageResponse, AIError, Service>
|
||||
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
|
||||
export function generate(input: ImageRequest | ImageRequestInput) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request)))
|
||||
}
|
||||
|
||||
export function stream<const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model>,
|
||||
): Stream.Stream<ImageEvent, AIError, Service>
|
||||
export function stream(input: ImageRequest): Stream.Stream<ImageEvent, AIError, Service>
|
||||
export function stream(input: ImageRequest | ImageRequestInput) {
|
||||
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request))))
|
||||
return Effect.try({
|
||||
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||
}
|
||||
|
||||
export const Image = {
|
||||
request,
|
||||
generate,
|
||||
stream,
|
||||
} as const
|
||||
|
||||
@@ -11,91 +11,9 @@ export type {
|
||||
Service as LLMClientService,
|
||||
} from "./route/client.js"
|
||||
export * from "./schema/index.js"
|
||||
export {
|
||||
ImageAspectRatio,
|
||||
ImageEvent,
|
||||
ImageModel,
|
||||
ImageModelSchema,
|
||||
ImageRequest,
|
||||
ImageResponse,
|
||||
ImageSize,
|
||||
} from "./image.js"
|
||||
export type {
|
||||
ImageFormat,
|
||||
ImageModelOptions,
|
||||
ImageOptions,
|
||||
ImageRequestFor,
|
||||
ImageRequestInput,
|
||||
ImageRoute,
|
||||
} from "./image.js"
|
||||
export { GeneratedImage, ImageInput, ImageInputSchema, ImageModel, ImageRequest, ImageResponse } from "./image.js"
|
||||
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image.js"
|
||||
export { Image } from "./image.js"
|
||||
export { VideoClient } from "./video-client.js"
|
||||
export {
|
||||
VideoAspectRatio,
|
||||
VideoEvent,
|
||||
VideoFrames,
|
||||
VideoModel,
|
||||
VideoModelSchema,
|
||||
VideoRequest,
|
||||
VideoResponse,
|
||||
} from "./video.js"
|
||||
export type {
|
||||
VideoModelOptions,
|
||||
VideoOptions,
|
||||
VideoRequestFor,
|
||||
VideoRequestInput,
|
||||
VideoResolution,
|
||||
VideoRoute,
|
||||
} from "./video.js"
|
||||
export { Video } from "./video.js"
|
||||
export { SpeechClient } from "./speech-client.js"
|
||||
export {
|
||||
SpeechEvent,
|
||||
SpeechModel,
|
||||
SpeechModelSchema,
|
||||
SpeechRequest,
|
||||
SpeechResponse,
|
||||
SpeechTimestamp,
|
||||
SpeechVoice,
|
||||
} from "./speech.js"
|
||||
export type {
|
||||
SpeechFormat,
|
||||
SpeechModelOptions,
|
||||
SpeechOptions,
|
||||
SpeechRequestFor,
|
||||
SpeechRequestInput,
|
||||
SpeechRoute,
|
||||
} from "./speech.js"
|
||||
export { Speech } from "./speech.js"
|
||||
export { TranscriptionClient } from "./transcription-client.js"
|
||||
export {
|
||||
TranscriptionEvent,
|
||||
TranscriptionModel,
|
||||
TranscriptionModelSchema,
|
||||
TranscriptionRequest,
|
||||
TranscriptionResponse,
|
||||
TranscriptionSegment,
|
||||
TranscriptionTimestamps,
|
||||
TranscriptionWord,
|
||||
} from "./transcription.js"
|
||||
export type {
|
||||
TranscriptionModelOptions,
|
||||
TranscriptionOptions,
|
||||
TranscriptionRequestFor,
|
||||
TranscriptionRequestInput,
|
||||
TranscriptionRoute,
|
||||
} from "./transcription.js"
|
||||
export { Transcription } from "./transcription.js"
|
||||
export { Media } from "./media.js"
|
||||
export { Generation } from "./generation.js"
|
||||
export type {
|
||||
AwaitOptions as GenerationAwaitOptions,
|
||||
Event as GenerationEvent,
|
||||
Poll,
|
||||
Route as GenerationRoute,
|
||||
Snapshot as GenerationSnapshot,
|
||||
Status as GenerationStatus,
|
||||
} from "./generation.js"
|
||||
export { Tool, ToolFailure, toDefinitions } from "./tool.js"
|
||||
export { ToolRuntime } from "./tool-runtime.js"
|
||||
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime.js"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { Endpoint } from "./route/endpoint.js"
|
||||
import type { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, InvalidRequestError, ModelID, ProviderID } from "./schema/index.js"
|
||||
|
||||
/**
|
||||
* What every media model carries: ids, the configured route, and deployment `http` overlays. Modality classes
|
||||
* (`ImageModel`, `VideoModel`, `SpeechModel`) extend it with their route type and a nominal marker so one cannot stand
|
||||
* in for the other in requests.
|
||||
*/
|
||||
export class MediaModel<Route, Options> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: Route
|
||||
readonly http?: HttpOptions
|
||||
|
||||
constructor(input: MediaModel.Input<Route>) {
|
||||
this.id = ModelID.make(input.id)
|
||||
this.provider = ProviderID.make(input.provider)
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
}
|
||||
|
||||
export namespace MediaModel {
|
||||
export interface Input<Route> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
readonly route: Route
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
/** A protocol plus its canonical start path; `ModelInput.baseURL` overrides `baseURL` per deployment. */
|
||||
export interface RouteInput<Request extends MediaRoute.MediaRequest, Protocol> {
|
||||
readonly id: string
|
||||
readonly provider: string | ProviderID
|
||||
readonly protocol: Protocol
|
||||
readonly path: Endpoint.EndpointPart<MediaProtocol.Body, Request>
|
||||
readonly baseURL?: string
|
||||
/** Headers the protocol requires on every call, such as a pinned API version; deployment headers win. */
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose a protocol route input with one deployment through `MediaRoute.inline`, `queued`, or `stream`. */
|
||||
export const composeRoute = <Request extends MediaRoute.MediaRequest, Protocol, Route>(
|
||||
compose: (input: MediaRoute.Composition<Request> & { readonly protocol: Protocol }) => Route,
|
||||
route: MediaModel.RouteInput<Request, Protocol>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): Route =>
|
||||
compose({
|
||||
id: route.id,
|
||||
provider: route.provider,
|
||||
protocol: route.protocol,
|
||||
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
|
||||
auth: input.auth,
|
||||
headers:
|
||||
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
|
||||
})
|
||||
|
||||
/** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
|
||||
export const tryRequest = <A>(make: () => A): Effect.Effect<A, AIError> =>
|
||||
Effect.try({
|
||||
try: make,
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -1,323 +0,0 @@
|
||||
export * as Media from "./media.js"
|
||||
|
||||
import { Effect, Encoding, FileSystem, Schema, SchemaGetter } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { ProviderID } from "./schema/ids.js"
|
||||
import { AIError, HttpContext, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js"
|
||||
import { ProviderMetadata } from "./schema/options.js"
|
||||
import { Service } from "./route/executor-service.js"
|
||||
import { detectMediaType, extensionMediaType } from "./utils/media-type.js"
|
||||
|
||||
export { detectMediaType } from "./utils/media-type.js"
|
||||
|
||||
const OCTET_STREAM = "application/octet-stream"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source — the serializable wire/persistence form of a media asset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BytesSource = Schema.Struct({
|
||||
type: Schema.Literal("bytes"),
|
||||
data: Schema.Uint8Array,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
|
||||
const Base64Source = Schema.Struct({
|
||||
type: Schema.Literal("base64"),
|
||||
data: Schema.String,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
|
||||
const UrlSource = Schema.Struct({
|
||||
type: Schema.Literal("url"),
|
||||
url: Schema.String,
|
||||
mediaType: Schema.optional(Schema.String),
|
||||
/** Epoch milliseconds after which the provider no longer serves the URL. */
|
||||
expiresAt: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
/** A provider-side handle: OpenAI `file_id`, Gemini file URI, `gs://`, `runway://`, or a prior generation id. */
|
||||
const RefSource = Schema.Struct({
|
||||
type: Schema.Literal("ref"),
|
||||
provider: ProviderID,
|
||||
id: Schema.String,
|
||||
mediaType: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const Source = Schema.Union([BytesSource, Base64Source, UrlSource, RefSource])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Media.Source" })
|
||||
export type Source = Schema.Schema.Type<typeof Source>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kind, Info, Notice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AspectRatio = `${number}:${number}`
|
||||
export const AspectRatio = Schema.declare<AspectRatio>(
|
||||
(value): value is AspectRatio => typeof value === "string" && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value),
|
||||
{ title: "Media.AspectRatio" },
|
||||
)
|
||||
|
||||
export const Kind = Schema.Literals(["image", "video", "audio", "document", "other"])
|
||||
export type Kind = Schema.Schema.Type<typeof Kind>
|
||||
|
||||
export const kindOf = (mediaType: string): Kind => {
|
||||
const lower = mediaType.toLowerCase()
|
||||
if (lower.startsWith("image/")) return "image"
|
||||
if (lower.startsWith("video/")) return "video"
|
||||
if (lower.startsWith("audio/")) return "audio"
|
||||
if (lower === "application/pdf" || lower.startsWith("text/")) return "document"
|
||||
return "other"
|
||||
}
|
||||
|
||||
/** Container-independent facts about the payload; raw PCM audio relies on these because it has no header. */
|
||||
export const Info = Schema.Struct({
|
||||
width: Schema.optional(Schema.Number),
|
||||
height: Schema.optional(Schema.Number),
|
||||
durationSeconds: Schema.optional(Schema.Number),
|
||||
sampleRate: Schema.optional(Schema.Number),
|
||||
channels: Schema.optional(Schema.Number),
|
||||
encoding: Schema.optional(Schema.String),
|
||||
format: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "Media.Info" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
/** A provider-side partial result such as stripped audio or a moderated sample; never a silent drop. */
|
||||
export const Notice = Schema.Struct({
|
||||
type: Schema.Literals(["moderated", "filtered", "other"]),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "Media.Notice" })
|
||||
export type Notice = Schema.Schema.Type<typeof Notice>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Asset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const invalid = (message: string, cause?: unknown) =>
|
||||
new AIError({ reason: new InvalidRequestError({ message, cause }) })
|
||||
|
||||
/** Synchronous view of an inline payload; `undefined` for `url` and `ref` sources, which carry no local bytes. */
|
||||
export interface Inline {
|
||||
readonly mime: string
|
||||
readonly base64: string
|
||||
readonly dataUrl: string
|
||||
}
|
||||
|
||||
export class Asset {
|
||||
readonly source: Source
|
||||
/** Derived from the source: declared type, sniffed magic bytes, then `application/octet-stream`. */
|
||||
readonly mediaType: string
|
||||
readonly kind: Kind
|
||||
readonly info?: Info
|
||||
/** Epoch milliseconds after which a `url` source stops resolving. */
|
||||
readonly expiresAt?: number
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
/** Transient download credentials for `url` sources; see `Asset.Input.headers`. */
|
||||
readonly headers?: Record<string, string>
|
||||
|
||||
// Derived payload forms are cached on the instance because every protocol lowering re-reads the same payload. The
|
||||
// cache is check-then-set (concurrent first reads of a `url` source may both download) and is never observable
|
||||
// through `source`, so round-tripping through `Media.from(asset.source)` stays lossless.
|
||||
#bytes: Uint8Array | undefined
|
||||
#base64: string | undefined
|
||||
|
||||
constructor(input: Asset.Input) {
|
||||
this.source = input.source
|
||||
this.mediaType =
|
||||
input.source.mediaType ??
|
||||
(input.source.type === "bytes" ? detectMediaType(input.source.data) : undefined) ??
|
||||
OCTET_STREAM
|
||||
this.kind = kindOf(this.mediaType)
|
||||
this.info = input.info
|
||||
this.expiresAt = input.source.type === "url" ? input.source.expiresAt : undefined
|
||||
this.providerMetadata = input.providerMetadata
|
||||
this.headers = input.source.type === "url" ? input.headers : undefined
|
||||
}
|
||||
|
||||
/** Inline payload without effects, for protocols that embed base64 or data URLs directly. */
|
||||
inline(): Inline | undefined {
|
||||
const source = this.source
|
||||
if (source.type !== "bytes" && source.type !== "base64") return undefined
|
||||
const base64 = source.type === "base64" ? source.data : (this.#base64 ??= Encoding.encodeBase64(source.data))
|
||||
const mime = this.mediaType.toLowerCase()
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}` }
|
||||
}
|
||||
|
||||
/** Decoded payload; downloads `url` sources through the request executor and caches the result. */
|
||||
bytes(): Effect.Effect<Uint8Array, AIError, Service> {
|
||||
return Effect.suspend(() => {
|
||||
const source = this.source
|
||||
if (source.type === "bytes") return Effect.succeed(source.data)
|
||||
if (this.#bytes !== undefined) return Effect.succeed(this.#bytes)
|
||||
if (source.type === "ref")
|
||||
return Effect.fail(invalid(`Cannot materialize provider ref ${source.provider}:${source.id}`))
|
||||
const decoded =
|
||||
source.type === "base64"
|
||||
? Effect.fromResult(Encoding.decodeBase64(source.data)).pipe(
|
||||
Effect.mapError((cause) => invalid(`Media asset contains invalid base64 data`, cause)),
|
||||
)
|
||||
: download(source, this.headers)
|
||||
return decoded.pipe(Effect.tap((data) => Effect.sync(() => (this.#bytes = data))))
|
||||
})
|
||||
}
|
||||
|
||||
base64(): Effect.Effect<string, AIError, Service> {
|
||||
return Effect.suspend(() => {
|
||||
const source = this.source
|
||||
if (source.type === "base64") return Effect.succeed(source.data)
|
||||
if (this.#base64 !== undefined) return Effect.succeed(this.#base64)
|
||||
return this.bytes().pipe(Effect.map((data) => (this.#base64 = Encoding.encodeBase64(data))))
|
||||
})
|
||||
}
|
||||
|
||||
dataUrl(): Effect.Effect<string, AIError, Service> {
|
||||
return this.base64().pipe(Effect.map((data) => `data:${this.mediaType};base64,${data}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* The `AssetEncoded` JSON form with `bytes` sources as base64, matching `Schema.toCodecJson(AssetSchema)`, so a
|
||||
* plain `JSON.stringify` of messages or events stays lossless and decodes back through the JSON codec.
|
||||
*/
|
||||
toJSON() {
|
||||
const source = this.source
|
||||
return {
|
||||
source: source.type === "bytes" ? { ...source, data: Encoding.encodeBase64(source.data) } : source,
|
||||
info: this.info,
|
||||
providerMetadata: this.providerMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull `url` sources into owned bytes before the URL expires. Inline sources return themselves. */
|
||||
materialize(): Effect.Effect<Asset, AIError, Service> {
|
||||
if (this.source.type === "bytes" || this.source.type === "base64") return Effect.succeed(this)
|
||||
return this.bytes().pipe(
|
||||
Effect.map((data) =>
|
||||
bytes(data, this.source.mediaType, { info: this.info, providerMetadata: this.providerMetadata }),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Asset {
|
||||
export interface Input {
|
||||
readonly source: Source
|
||||
readonly info?: Info
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
/**
|
||||
* Headers required to download a `url` source, such as the provider API key Veo demands for its file URIs.
|
||||
* They are runtime-only: never part of `source`, `toJSON()`, or `AssetSchema`, so a persisted asset cannot leak
|
||||
* credentials and cannot be downloaded again after a round-trip. Call `materialize()` before persisting.
|
||||
*/
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON form of an asset: the serializable `Source` plus caller-supplied metadata. `bytes` sources encode as base64. */
|
||||
export const AssetEncoded = Schema.Struct({
|
||||
source: Source,
|
||||
info: Schema.optional(Info),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "Media.AssetEncoded" })
|
||||
|
||||
const encodeAsset = (asset: Asset): typeof AssetEncoded.Type => ({
|
||||
source: asset.source,
|
||||
info: asset.info,
|
||||
providerMetadata: asset.providerMetadata,
|
||||
})
|
||||
|
||||
const AssetInstance = Schema.declare((value): value is Asset => value instanceof Asset, {
|
||||
expected: "Media.Asset",
|
||||
})
|
||||
|
||||
/** `Asset` in the type domain and `AssetEncoded` on the wire, so messages and events holding assets serialize. */
|
||||
export const AssetSchema = AssetEncoded.pipe(
|
||||
Schema.decodeTo(AssetInstance, {
|
||||
decode: SchemaGetter.transform((encoded) => new Asset(encoded)),
|
||||
encode: SchemaGetter.transform(encodeAsset),
|
||||
}),
|
||||
)
|
||||
|
||||
const download = Effect.fn("Media.download")(function* (
|
||||
source: Extract<Source, { readonly type: "url" }>,
|
||||
headers: Record<string, string> | undefined,
|
||||
) {
|
||||
const executor = yield* Service
|
||||
const response = yield* executor.execute(
|
||||
HttpClientRequest.get(source.url).pipe(HttpClientRequest.setHeaders(headers ?? {})),
|
||||
)
|
||||
const buffer = yield* response.arrayBuffer.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: `Failed to read media from ${source.url}`,
|
||||
http: new HttpContext({ url: response.request.url, status: response.status, headers: response.headers }),
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return new Uint8Array(buffer)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AssetOptions = Omit<Asset.Input, "source">
|
||||
|
||||
export const from = (source: Source, options?: AssetOptions) => new Asset({ ...options, source })
|
||||
|
||||
export const bytes = (data: Uint8Array, mediaType?: string, options?: AssetOptions) =>
|
||||
from({ type: "bytes", data, mediaType: mediaType ?? detectMediaType(data) ?? OCTET_STREAM }, options)
|
||||
|
||||
export const base64 = (data: string, mediaType: string, options?: AssetOptions) =>
|
||||
from({ type: "base64", data, mediaType }, options)
|
||||
|
||||
export const url = (
|
||||
value: string,
|
||||
options?: AssetOptions & Omit<Extract<Source, { readonly type: "url" }>, "type" | "url">,
|
||||
) => {
|
||||
const { mediaType, expiresAt, ...rest } = options ?? {}
|
||||
return from({ type: "url", url: value, mediaType, expiresAt }, rest)
|
||||
}
|
||||
|
||||
export const ref = (provider: string | ProviderID, id: string, mediaType?: string, options?: AssetOptions) =>
|
||||
from({ type: "ref", provider: ProviderID.make(provider), id, mediaType }, options)
|
||||
|
||||
const DATA_URL = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/s
|
||||
|
||||
/** Parse a `data:<mime>;base64,<data>` URL, or `undefined` when the value is not a base64 data URL. */
|
||||
export const parseDataUrl = (value: string, options?: AssetOptions) => {
|
||||
const match = DATA_URL.exec(value)
|
||||
return match === null ? undefined : base64(match[2], match[1], options)
|
||||
}
|
||||
|
||||
/** Parse a `data:<mime>;base64,<data>` URL. Malformed input throws a typed `AIError` because constructors are sync. */
|
||||
export const fromDataUrl = (dataUrl: string, options?: AssetOptions) => {
|
||||
const asset = parseDataUrl(dataUrl, options)
|
||||
if (asset === undefined) throw invalid("Media data URLs must contain a MIME type and base64 data")
|
||||
return asset
|
||||
}
|
||||
|
||||
/** Read a file through `FileSystem` and sniff its media type from magic bytes, then the extension. */
|
||||
export const file = (path: string, options?: AssetOptions): Effect.Effect<Asset, AIError, FileSystem.FileSystem> =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const data = yield* fs
|
||||
.readFile(path)
|
||||
.pipe(Effect.mapError((cause) => invalid(`Failed to read media file ${path}`, cause)))
|
||||
return bytes(data, detectMediaType(data) ?? extensionMediaType(path), options)
|
||||
})
|
||||
|
||||
/** Materialize an asset and write its bytes through `FileSystem`. */
|
||||
export const write = (asset: Asset, path: string): Effect.Effect<void, AIError, FileSystem.FileSystem | Service> =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const data = yield* asset.bytes()
|
||||
yield* fs
|
||||
.writeFile(path, data)
|
||||
.pipe(Effect.mapError((cause) => invalid(`Failed to write media file ${path}`, cause)))
|
||||
})
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
|
||||
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
|
||||
import { Image, ImageModel, ImageRequest, type ImageRequestInput } from "./image.js"
|
||||
import { ImageClient } from "./image-client.js"
|
||||
import { LLM } from "./index.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { LanguageModel, LLMRequest } from "./schema/index.js"
|
||||
import type { RequestInput } from "./llm.js"
|
||||
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
|
||||
import { SpeechClient } from "./speech-client.js"
|
||||
import {
|
||||
Transcription,
|
||||
TranscriptionModel,
|
||||
TranscriptionRequest,
|
||||
type TranscriptionOptions,
|
||||
type TranscriptionRequestInput,
|
||||
} from "./transcription.js"
|
||||
import { TranscriptionClient } from "./transcription-client.js"
|
||||
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
|
||||
import { VideoClient } from "./video-client.js"
|
||||
|
||||
/**
|
||||
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
|
||||
* and transcription clients over a request executor; every method runs the corresponding Effect API and rethrows
|
||||
* `AIError` unchanged.
|
||||
*/
|
||||
export interface Options {
|
||||
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or middleware here. */
|
||||
readonly layer?: Layer.Layer<RequestExecutor.Service>
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type Services =
|
||||
| Layer.Success<typeof LLMClient.layer>
|
||||
| Layer.Success<typeof ImageClient.layer>
|
||||
| Layer.Success<typeof VideoClient.layer>
|
||||
| Layer.Success<typeof SpeechClient.layer>
|
||||
| Layer.Success<typeof TranscriptionClient.layer>
|
||||
| RequestExecutor.Service
|
||||
|
||||
/** Promise view of a `Generation`: its snapshot plus `await`, `refresh`, and `cancel` returning promises. */
|
||||
export type GenerationHandle<Response> = Snapshot & {
|
||||
/** Serializable JSON; pass it back to `resume` from another process. */
|
||||
readonly token: unknown
|
||||
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
|
||||
readonly refresh: (options?: RunOptions) => Promise<GenerationHandle<Response>>
|
||||
readonly cancel: (options?: RunOptions) => Promise<void>
|
||||
}
|
||||
|
||||
const abortEffect = (signal: AbortSignal | undefined) =>
|
||||
signal === undefined
|
||||
? Effect.never
|
||||
: Effect.callback<void>((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.void)
|
||||
return
|
||||
}
|
||||
const onAbort = () => resume(Effect.void)
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
|
||||
})
|
||||
|
||||
export const make = (options: Options = {}) => {
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
LLMClient.layer,
|
||||
ImageClient.layer,
|
||||
VideoClient.layer,
|
||||
SpeechClient.layer,
|
||||
TranscriptionClient.layer,
|
||||
).pipe(Layer.provideMerge(options.layer ?? RequestExecutor.fetchLayer)),
|
||||
)
|
||||
|
||||
/** Run any package Effect (for example `asset.bytes()`) inside this runtime. */
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
|
||||
runtime.runPromise(effect, { signal: options?.signal })
|
||||
|
||||
const iterate = <A, E>(stream: Stream.Stream<A, E, Services>, options?: RunOptions): AsyncIterable<A> =>
|
||||
Stream.toAsyncIterable(
|
||||
Stream.unwrap(
|
||||
runtime.contextEffect.pipe(
|
||||
Effect.map(
|
||||
(context): Stream.Stream<A, E> =>
|
||||
stream.pipe(Stream.interruptWhen(abortEffect(options?.signal)), Stream.provideContext(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const handle = <Response>(generation: Generation<Response>): GenerationHandle<Response> => ({
|
||||
...generation.snapshot,
|
||||
token: generation.token,
|
||||
await: (options) => run(generation.await({ poll: options?.poll }), options),
|
||||
refresh: (options) => run(generation.refresh(), options).then(handle),
|
||||
cancel: (options) => run(generation.cancel(), options),
|
||||
})
|
||||
|
||||
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
|
||||
const llmRequest = (input: RequestInput | LLMRequest) => (input instanceof LLMRequest ? input : LLM.request(input))
|
||||
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
|
||||
input instanceof ImageRequest ? input : Image.request(input)
|
||||
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
|
||||
input instanceof VideoRequest ? input : Video.request(input)
|
||||
const speechRequest = (input: SpeechRequestInput | SpeechRequest) =>
|
||||
input instanceof SpeechRequest ? input : Speech.request(input)
|
||||
const transcriptionRequest = (input: TranscriptionRequestInput | TranscriptionRequest) =>
|
||||
input instanceof TranscriptionRequest ? input : Transcription.request(input)
|
||||
|
||||
return {
|
||||
run,
|
||||
llm: {
|
||||
request: LLM.request,
|
||||
generate: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
run(LLM.generate(llmRequest(input)), options),
|
||||
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
iterate(LLM.stream(llmRequest(input)), options),
|
||||
},
|
||||
image: {
|
||||
request: Image.request,
|
||||
generate: <const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model> | ImageRequest,
|
||||
options?: RunOptions,
|
||||
) => run(Image.generate(imageRequest(input)), options),
|
||||
stream: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
|
||||
iterate(Image.stream(imageRequest(input)), options),
|
||||
},
|
||||
video: {
|
||||
request: Video.request,
|
||||
start: <const Model extends VideoModel>(input: VideoRequestInput<Model> | VideoRequest, options?: RunOptions) =>
|
||||
run(Video.start(videoRequest(input)), options).then(handle),
|
||||
generate: <const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model> | VideoRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => run(Video.generate(videoRequest(input), { poll: options?.poll }), options),
|
||||
resume: <Options extends VideoOptions>(model: VideoModel<Options>, token: unknown, options?: RunOptions) =>
|
||||
run(Video.resume(model, token), options).then(handle),
|
||||
stream: <const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model> | VideoRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => iterate(Video.stream(videoRequest(input), { poll: options?.poll }), options),
|
||||
},
|
||||
speech: {
|
||||
request: Speech.request,
|
||||
generate: <const Model extends SpeechModel>(
|
||||
input: SpeechRequestInput<Model> | SpeechRequest,
|
||||
options?: RunOptions,
|
||||
) => run(Speech.generate(speechRequest(input)), options),
|
||||
stream: <const Model extends SpeechModel>(
|
||||
input: SpeechRequestInput<Model> | SpeechRequest,
|
||||
options?: RunOptions,
|
||||
) => iterate(Speech.stream(speechRequest(input)), options),
|
||||
},
|
||||
transcription: {
|
||||
request: Transcription.request,
|
||||
generate: <const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => run(Transcription.generate(transcriptionRequest(input), { poll: options?.poll }), options),
|
||||
stream: <const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => iterate(Transcription.stream(transcriptionRequest(input), { poll: options?.poll }), options),
|
||||
start: <const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
|
||||
options?: RunOptions,
|
||||
) => run(Transcription.start(transcriptionRequest(input)), options).then(handle),
|
||||
resume: <Options extends TranscriptionOptions>(
|
||||
model: TranscriptionModel<Options>,
|
||||
token: unknown,
|
||||
options?: RunOptions,
|
||||
) => run(Transcription.resume(model, token), options).then(handle),
|
||||
},
|
||||
dispose: () => runtime.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
export type Client = ReturnType<typeof make>
|
||||
|
||||
/** Default client over `RequestExecutor.fetchLayer` for scripts; the runtime builds its layer on first use. */
|
||||
export const ai = make()
|
||||
|
||||
export * as AI from "./promise.js"
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import type { LanguageModelCompatibility } from "../schema/index.js"
|
||||
import { OpenAIChat } from "./openai-chat.js"
|
||||
import { JsonObject, ProviderShared } from "./shared.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
|
||||
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
thinkingBudget: Schema.optional(Schema.Int),
|
||||
preserveThinking: Schema.optional(Schema.Boolean),
|
||||
clearThinking: Schema.optional(Schema.Boolean),
|
||||
thinking: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.declare<"adaptive" | "disabled" | (string & {})>(Schema.is(Schema.String)),
|
||||
}),
|
||||
),
|
||||
toolStream: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
repetitionPenalty: Schema.optional(Schema.Number),
|
||||
responseFormat: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.declare<"text" | "json_object" | "json_schema" | (string & {})>(Schema.is(Schema.String)),
|
||||
json_schema: Schema.optional(JsonObject),
|
||||
}),
|
||||
),
|
||||
enableSearch: Schema.optional(Schema.Boolean),
|
||||
searchOptions: Schema.optional(
|
||||
Schema.Struct({
|
||||
forced_search: Schema.optional(Schema.Boolean),
|
||||
search_strategy: Schema.optional(
|
||||
Schema.declare<"turbo" | "max" | "agent" | "agent_max" | (string & {})>(Schema.is(Schema.String)),
|
||||
),
|
||||
enable_search_extension: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type OptionsInput = typeof Options.Type
|
||||
|
||||
export const compatibility = {
|
||||
maxTokensField: "max_completion_tokens",
|
||||
supportsStore: false,
|
||||
supportsStrictMode: false,
|
||||
reasoningField: "reasoning_content",
|
||||
zaiToolStream: false,
|
||||
} satisfies LanguageModelCompatibility
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "alibaba-chat",
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
...OpenAIChat.bodyFields,
|
||||
enable_thinking: Options.fields.enableThinking,
|
||||
thinking_budget: Options.fields.thinkingBudget,
|
||||
preserve_thinking: Options.fields.preserveThinking,
|
||||
clear_thinking: Options.fields.clearThinking,
|
||||
thinking: Options.fields.thinking,
|
||||
parallel_tool_calls: Options.fields.parallelToolCalls,
|
||||
repetition_penalty: Options.fields.repetitionPenalty,
|
||||
top_k: Schema.optional(Schema.Int),
|
||||
response_format: Options.fields.responseFormat,
|
||||
enable_search: Options.fields.enableSearch,
|
||||
search_options: Options.fields.searchOptions,
|
||||
}),
|
||||
from: Effect.fn("AlibabaChat.fromRequest")(function* (req) {
|
||||
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
|
||||
return {
|
||||
...(yield* OpenAIChat.protocol.body.from(req)),
|
||||
enable_thinking: opts.enableThinking,
|
||||
thinking_budget: opts.thinkingBudget,
|
||||
preserve_thinking: opts.preserveThinking,
|
||||
clear_thinking: opts.clearThinking,
|
||||
thinking: opts.thinking,
|
||||
tool_stream: opts.toolStream,
|
||||
parallel_tool_calls:
|
||||
opts.parallelToolCalls ??
|
||||
(req.toolChoice?.disableParallelToolUse === undefined ? undefined : !req.toolChoice.disableParallelToolUse),
|
||||
repetition_penalty: opts.repetitionPenalty,
|
||||
top_k: req.generation?.topK,
|
||||
response_format: opts.responseFormat,
|
||||
enable_search: opts.enableSearch,
|
||||
search_options: opts.searchOptions,
|
||||
}
|
||||
}),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
export * as AlibabaChat from "./alibaba-chat.js"
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { LLMRequest } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "./anthropic-messages.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
|
||||
const Options = Schema.Struct({
|
||||
effort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
thinking: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.declare<"enabled" | "disabled" | (string & {})>(Schema.is(Schema.String)),
|
||||
budgetTokens: Schema.optional(Schema.Int),
|
||||
budget_tokens: Schema.optional(Schema.Int),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type OptionsInput = typeof Options.Type & Pick<AnthropicMessages.OptionsInput, "outputConfig">
|
||||
export const protocol = Protocol.make({
|
||||
id: "alibaba-messages",
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
...AnthropicMessages.AnthropicMessagesBody.fields,
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.String, budget_tokens: Schema.optional(Schema.Int) })),
|
||||
}),
|
||||
from: Effect.fn("AlibabaMessages.fromRequest")(function* (req) {
|
||||
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
|
||||
// Model Studio accepts enabled thinking without Anthropic's mandatory token budget.
|
||||
return {
|
||||
...(yield* AnthropicMessages.protocol.body.from(
|
||||
LLMRequest.update(req, {
|
||||
providerOptions: { ...req.providerOptions, thinking: undefined },
|
||||
}),
|
||||
)),
|
||||
thinking:
|
||||
opts.thinking === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: opts.thinking.type,
|
||||
budget_tokens: opts.thinking.budgetTokens ?? opts.thinking.budget_tokens,
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
})
|
||||
|
||||
export * as AlibabaMessages from "./alibaba-messages.js"
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
previousResponseId: Schema.optional(Schema.String),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
})
|
||||
export type OptionsInput = typeof Options.Type
|
||||
const NativeTool = Schema.Struct({ type: Schema.Literals(["web_search", "web_extractor", "code_interpreter"]) })
|
||||
const WebExtractorItem = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("web_extractor_call"),
|
||||
id: Schema.String,
|
||||
urls: Schema.optional(Schema.Array(Schema.String)),
|
||||
goal: Schema.optional(Schema.String),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
const Body = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, WebExtractorItem])),
|
||||
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
|
||||
enable_thinking: Options.fields.enableThinking,
|
||||
previous_response_id: Options.fields.previousResponseId,
|
||||
conversation: Options.fields.conversation,
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
const adapter = {
|
||||
id: "alibaba-responses",
|
||||
name: "Alibaba Responses",
|
||||
nativeTool: (native) => ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(native.alibaba),
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(WebExtractorItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const tools = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
code_interpreter_call: { name: "code_interpreter", input: (item) => ({ code: item.code }) },
|
||||
} satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: adapter.id,
|
||||
body: {
|
||||
schema: Body,
|
||||
from: Effect.fn("AlibabaResponses.fromRequest")(function* (req) {
|
||||
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
|
||||
const body = yield* OpenResponses.fromRequestWithAdapter(req, adapter)
|
||||
const choice = body.tool_choice
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
|
||||
...body,
|
||||
enable_thinking: opts.enableThinking,
|
||||
previous_response_id: opts.previousResponseId,
|
||||
conversation: opts.conversation,
|
||||
// Model Studio expresses named selection through allowed_tools.
|
||||
tool_choice:
|
||||
typeof choice === "object" && choice.type === "function"
|
||||
? { type: "allowed_tools" as const, mode: "required" as const, tools: [choice] }
|
||||
: choice,
|
||||
})
|
||||
}),
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (req) => OpenResponses.initial(req, adapter),
|
||||
step: (state, input) =>
|
||||
Effect.gen(function* () {
|
||||
const event = OpenResponses.normalize(state, input)
|
||||
if (event.type !== "response.output_item.done" || !event.item) return yield* OpenResponses.step(state, event)
|
||||
if (event.item.type === "web_extractor_call") {
|
||||
const item = yield* Schema.decodeUnknownEffect(WebExtractorItem)(event.item).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
adapter.id,
|
||||
"Alibaba returned an invalid web extraction item",
|
||||
ProviderShared.encodeJson(event),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* ResponsesHostedTools.onDone(state, item, {
|
||||
web_extractor_call: { name: "web_extractor", input: () => ({ urls: item.urls, goal: item.goal }) },
|
||||
})
|
||||
}
|
||||
if (ResponsesHostedTools.isItem(event.item, tools))
|
||||
return yield* ResponsesHostedTools.onDone(state, event.item, tools)
|
||||
return yield* OpenResponses.step(state, event)
|
||||
}),
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
})
|
||||
|
||||
export * as AlibabaResponses from "./alibaba-responses.js"
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
@@ -21,14 +21,12 @@ import {
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -38,7 +36,6 @@ const ADAPTER = "anthropic-messages"
|
||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
export const DEFAULT_MAX_TOKENS = 32_000
|
||||
const DEFAULT_EFFORT = "high"
|
||||
|
||||
const SSE_EVENTS = new Set([
|
||||
"message",
|
||||
@@ -53,10 +50,48 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
|
||||
export type ThinkingInput = typeof Thinking.Encoded
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
readonly service_tier?: "auto" | "standard_only"
|
||||
readonly serviceTier?: "auto" | "standard_only"
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?:
|
||||
| string
|
||||
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
readonly outputConfig?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
@@ -83,7 +118,6 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -243,11 +277,7 @@ type AnthropicToolResultBlock = Schema.Schema.Type<typeof AnthropicToolResultBlo
|
||||
const AnthropicMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }),
|
||||
Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Array(AnthropicTextBlock),
|
||||
output_config: Schema.optional(Schema.Struct({ effort: Schema.String })),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.Array(AnthropicTextBlock) }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type AnthropicMessage = Schema.Schema.Type<typeof AnthropicMessage>
|
||||
|
||||
@@ -271,23 +301,20 @@ const AnthropicToolChoice = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
|
||||
})
|
||||
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(knownString<"summarized" | "omitted">()),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
...AnthropicThinkingFields,
|
||||
})
|
||||
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
|
||||
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
const AnthropicJsonOutputFormat = Schema.Struct({
|
||||
@@ -311,53 +338,6 @@ const AnthropicContainer = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
|
||||
// keys are disjoint per variant so the input type requires exactly one and the
|
||||
// transform can narrow on it. Decoding straight to the wire block keeps the
|
||||
// alias out of the rest of the file.
|
||||
const ThinkingEnabledInput = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
]).pipe(
|
||||
Schema.decodeTo(AnthropicThinkingEnabled, {
|
||||
decode: SchemaGetter.transform((input) => ({
|
||||
type: "enabled" as const,
|
||||
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
|
||||
display: input.display,
|
||||
block_binding: input.block_binding,
|
||||
})),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
|
||||
const OutputConfigInput = Schema.Struct({
|
||||
effort: optionalNull(Schema.String),
|
||||
format: optionalNull(AnthropicJsonOutputFormat),
|
||||
})
|
||||
|
||||
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
|
||||
const Options = Schema.Struct({
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
contextManagement: Schema.optional(ContextManagement),
|
||||
thinking: Schema.optional(Thinking),
|
||||
effort: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
serviceTier: Schema.optional(AnthropicServiceTier),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
container: Schema.optional(AnthropicContainer),
|
||||
inference_geo: optionalNull(Schema.String),
|
||||
inferenceGeo: optionalNull(Schema.String),
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
cacheControl: Schema.optional(AnthropicCacheControl),
|
||||
output_config: Schema.optional(OutputConfigInput),
|
||||
outputConfig: Schema.optional(OutputConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
@@ -389,7 +369,7 @@ const AnthropicBodyFields = {
|
||||
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
|
||||
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -658,7 +638,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
breakpoints?: Cache.Breakpoints,
|
||||
) {
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
|
||||
const fileId = fileIdFromMetadata(part.metadata)
|
||||
|
||||
@@ -687,9 +667,9 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
} satisfies AnthropicDocumentBlock
|
||||
}
|
||||
|
||||
const rawString = ProviderShared.mediaUrl(part.media)?.trim()
|
||||
const rawString = typeof part.data === "string" ? part.data.trim() : undefined
|
||||
// SDK URL sources: URLImageSource:3817 / URLPDFSource:3823 {type:"url", url}
|
||||
if (rawString && isHttpUrl(rawString)) {
|
||||
if (rawString && isHttpUrl(rawString) && !rawString.startsWith("data:")) {
|
||||
if (mime.startsWith("image/"))
|
||||
return {
|
||||
type: "image" as const,
|
||||
@@ -714,11 +694,20 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
} satisfies AnthropicDocumentBlock
|
||||
}
|
||||
|
||||
const media = yield* ProviderShared.requireInlineMedia("Anthropic Messages", part.media)
|
||||
|
||||
// SDK PlainTextSource:2716 {type:"text", media_type:"text/plain", data}
|
||||
if (mime === "text/plain") {
|
||||
const textData = Buffer.from(media.base64, "base64").toString("utf8")
|
||||
const textData =
|
||||
typeof part.data !== "string"
|
||||
? Buffer.from(part.data).toString("utf8")
|
||||
: part.data.startsWith("data:")
|
||||
? (() => {
|
||||
const comma = part.data.indexOf(",")
|
||||
const payload = comma >= 0 ? part.data.slice(comma + 1) : part.data
|
||||
return part.data.includes(";base64")
|
||||
? Buffer.from(payload, "base64").toString("utf8")
|
||||
: decodeURIComponent(payload)
|
||||
})()
|
||||
: part.data
|
||||
return {
|
||||
type: "document" as const,
|
||||
source: { type: "text" as const, media_type: "text/plain" as const, data: textData },
|
||||
@@ -733,6 +722,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
} satisfies AnthropicDocumentBlock
|
||||
}
|
||||
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
if (media.mime === "application/pdf")
|
||||
return {
|
||||
type: "document" as const,
|
||||
@@ -751,7 +741,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
: { citations: citationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicDocumentBlock
|
||||
if (!media.mime.startsWith("image/"))
|
||||
return yield* invalid(`Anthropic Messages does not support media type ${part.media.mediaType}`)
|
||||
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: {
|
||||
@@ -770,7 +760,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fnUntraced(function* (item: Tool.Content) {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||
return yield* lowerMedia(ProviderShared.toolFileMedia(item))
|
||||
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||
})
|
||||
|
||||
const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart) {
|
||||
@@ -871,12 +861,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
const update = effortUpdate(message)
|
||||
if (update) {
|
||||
// Accepted at any position, so the text-update placement rules do not apply.
|
||||
messages.push({ role: "system", content: [], output_config: { effort: update.effort ?? DEFAULT_EFFORT } })
|
||||
continue
|
||||
}
|
||||
if (splitsLocalToolResults(request.messages, index))
|
||||
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
|
||||
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
|
||||
@@ -989,57 +973,101 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
id.toLowerCase(),
|
||||
)?.groups
|
||||
if (!match) return undefined
|
||||
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
|
||||
}
|
||||
|
||||
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsThinkingBlockBinding
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(model.id)
|
||||
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
|
||||
}
|
||||
|
||||
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(model.id)
|
||||
if (version === undefined) return false
|
||||
if (version.family === "opus") return version.major >= 5
|
||||
if (version.family !== "fable" && version.family !== "mythos") return false
|
||||
return version.major > 5 || (version.major === 5 && version.minor >= 1)
|
||||
}
|
||||
|
||||
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
|
||||
if (thinking?.type === "disabled") return thinking
|
||||
if (!supportsThinkingBlockBinding(model)) return thinking
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier =
|
||||
(input as Record<string, unknown> | undefined)?.service_tier ??
|
||||
(input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as
|
||||
| string
|
||||
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
(input as Record<string, unknown> | undefined)?.inferenceGeo
|
||||
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
|
||||
const rawCacheControl =
|
||||
(input as Record<string, unknown> | undefined)?.cache_control ??
|
||||
(input as Record<string, unknown> | undefined)?.cacheControl
|
||||
const cache_control =
|
||||
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
|
||||
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
|
||||
: undefined
|
||||
const rawOutputConfig =
|
||||
(input as Record<string, unknown> | undefined)?.output_config ??
|
||||
(input as Record<string, unknown> | undefined)?.outputConfig
|
||||
const outputConfigEffort =
|
||||
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
|
||||
? ((input as Record<string, unknown>).effort as string)
|
||||
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
|
||||
? (rawOutputConfig.effort as string)
|
||||
: undefined
|
||||
const outputConfigFormat =
|
||||
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
|
||||
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
|
||||
: undefined
|
||||
const output_config =
|
||||
outputConfigEffort === undefined && outputConfigFormat === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
|
||||
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
|
||||
}
|
||||
return {
|
||||
...(thinking ?? { type: "adaptive" as const }),
|
||||
block_binding: {
|
||||
prefix_mismatch_behavior: "drop_block",
|
||||
...thinking?.block_binding,
|
||||
},
|
||||
thinking: yield* resolveThinking(input?.thinking),
|
||||
effort: outputConfigEffort,
|
||||
output_config,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
inference_geo,
|
||||
cache_control,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const management = options.contextManagement
|
||||
const outputConfig = options.output_config ?? options.outputConfig
|
||||
const format = outputConfig?.format ?? undefined
|
||||
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const flattened = ProviderShared.flattenToolRequest(updates.request)
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const tools =
|
||||
flattened.tools.length === 0
|
||||
? undefined
|
||||
@@ -1067,8 +1095,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const options = yield* resolveOptions(request)
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1081,14 +1108,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
output_config,
|
||||
thinking: options.thinking,
|
||||
output_config: options.output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
cache_control: options.cache_control,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
|
||||
inference_geo: options.inference_geo,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier ?? options.serviceTier,
|
||||
service_tier: options.service_tier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
@@ -1607,24 +1634,26 @@ export const protocol = Protocol.make({
|
||||
}),
|
||||
step,
|
||||
},
|
||||
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
|
||||
})
|
||||
|
||||
export const transport = <
|
||||
Body extends Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">,
|
||||
>() => {
|
||||
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
|
||||
const http = HttpTransport.httpJson<Body, string>({ framing })
|
||||
return {
|
||||
...http,
|
||||
prepare: (input: Parameters<typeof http.prepare>[0]) => {
|
||||
const requiredBetas = requiredBetaHeaders(input.body)
|
||||
if (requiredBetas.length === 0) return http.prepare(input)
|
||||
if (
|
||||
!input.body.context_management?.edits.length &&
|
||||
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
|
||||
)
|
||||
return http.prepare(input)
|
||||
const headers = Headers.fromInput(input.request.http?.headers)
|
||||
const existingBetas = (headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const betas = new Set([...existingBetas, ...requiredBetas])
|
||||
const betas = new Set(
|
||||
(headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
betas.add("compact-2026-01-12")
|
||||
return http.prepare({
|
||||
...input,
|
||||
request: LLMRequest.update(input.request, {
|
||||
@@ -1638,26 +1667,6 @@ export const transport = <
|
||||
}
|
||||
}
|
||||
|
||||
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
|
||||
// Always request interleaved thinking. The API accepts the header on any
|
||||
// model and ignores it where unsupported, while manual-thinking models need
|
||||
// it for thinking between tool calls.
|
||||
const betas: string[] = ["interleaved-thinking-2025-05-14"]
|
||||
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
|
||||
const replaysCompaction = body.messages.some((message) =>
|
||||
message.content.some((block) => block.type === "compaction"),
|
||||
)
|
||||
if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12")
|
||||
|
||||
if (body.messages.some((message) => message.role === "system" && message.output_config !== undefined))
|
||||
betas.push("mid-conversation-output-config-2026-07-01")
|
||||
|
||||
const thinking = body.thinking
|
||||
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
|
||||
betas.push("thinking-binding-controls-2026-08-01")
|
||||
return betas
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "anthropic",
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
const ADAPTER = "assemblyai-transcription"
|
||||
const NAME = "AssemblyAI"
|
||||
const PROVIDER = ProviderID.make("assemblyai")
|
||||
export const DEFAULT_BASE_URL = "https://api.assemblyai.com"
|
||||
export const PATH = "/v2/transcript"
|
||||
export const UPLOAD_PATH = "/v2/upload"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AssemblyAITranscriptionOptions = {
|
||||
readonly keyterms_prompt?: ReadonlyArray<string>
|
||||
readonly punctuate?: boolean
|
||||
readonly format_text?: boolean
|
||||
readonly disfluencies?: boolean
|
||||
readonly filter_profanity?: boolean
|
||||
readonly temperature?: number
|
||||
readonly speaker_options?: { readonly min_speakers_expected?: number; readonly max_speakers_expected?: number }
|
||||
readonly language_detection_options?: {
|
||||
readonly expected_languages?: ReadonlyArray<string>
|
||||
readonly fallback_language?: string
|
||||
readonly code_switching?: boolean
|
||||
}
|
||||
readonly speech_models?: ReadonlyArray<"universal-3-5-pro" | "universal-2" | (string & {})>
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = TranscriptionRequestFor<AssemblyAITranscriptionOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Token = Schema.Struct({ transcriptID: Schema.String })
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const Upload = Schema.Struct({ upload_url: Schema.String })
|
||||
|
||||
/** Word and utterance times are milliseconds. */
|
||||
const Transcript = Schema.Struct({
|
||||
id: Schema.String,
|
||||
status: Schema.String,
|
||||
text: optionalNull(Schema.String),
|
||||
words: optionalNull(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
text: Schema.String,
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
confidence: optionalNull(Schema.Number),
|
||||
speaker: optionalNull(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
utterances: optionalNull(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
text: Schema.String,
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
speaker: optionalNull(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
language_code: optionalNull(Schema.String),
|
||||
audio_duration: optionalNull(Schema.Number),
|
||||
speech_model_used: optionalNull(Schema.String),
|
||||
error: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const STATUS = {
|
||||
queued: "queued",
|
||||
processing: "running",
|
||||
completed: "completed",
|
||||
error: "failed",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeUpload = MediaProtocol.decodeJson(ADAPTER, NAME, Upload)
|
||||
|
||||
/** `/v2/transcript` only takes a URL, so inline audio is uploaded to `/v2/upload` first. */
|
||||
const prepare = Effect.fn("AssemblyAITranscription.prepare")(function* (request: Request, send: MediaProtocol.Send) {
|
||||
if (request.audio.source.type !== "bytes" && request.audio.source.type !== "base64") return request
|
||||
const audio = yield* MediaInput.inlineBytes(ADAPTER, request.audio)
|
||||
const uploaded = yield* send(UPLOAD_PATH, MediaProtocol.binary(audio, "application/octet-stream")).pipe(
|
||||
Effect.flatMap(decodeUpload),
|
||||
)
|
||||
return { ...request, audio: Media.url(uploaded.value.upload_url, { mediaType: request.audio.mediaType }) }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (request: Request) {
|
||||
const audio = yield* ProviderShared.mediaReference(request.audio, PROVIDER, NAME)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
audio_url: audio.value,
|
||||
speech_models: [request.model.id],
|
||||
language_code: request.language,
|
||||
language_detection: request.language === undefined ? true : undefined,
|
||||
prompt: request.prompt,
|
||||
// Turn-level `utterances`, the only segments AssemblyAI returns, require speaker labels.
|
||||
speaker_labels: request.diarize === true || request.timestamps === "segment" ? true : undefined,
|
||||
speakers_expected: request.speakers,
|
||||
},
|
||||
request.providerOptions,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeTranscript = MediaProtocol.decodeJson(ADAPTER, NAME, Transcript)
|
||||
|
||||
const decodeStart = Effect.fn("AssemblyAITranscription.decodeStart")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const output = yield* decodeTranscript(response)
|
||||
const status = yield* MediaProtocol.status(STATUS, output.value.status, output)
|
||||
return { token: { transcriptID: output.value.id }, snapshot: { id: output.value.id, status } }
|
||||
})
|
||||
|
||||
const decodeStatus = Effect.fn("AssemblyAITranscription.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeTranscript(response)
|
||||
const status = yield* MediaProtocol.status(STATUS, output.value.status, output)
|
||||
return { id: context.token.transcriptID, status }
|
||||
})
|
||||
|
||||
const seconds = (milliseconds: number) => milliseconds / 1000
|
||||
|
||||
const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeTranscript(response)
|
||||
const transcript = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, transcript.status, output)
|
||||
const error = transcript.error ?? undefined
|
||||
if (status === "failed")
|
||||
return yield* output.ended("failed", `${NAME} transcription failed${error === undefined ? "" : `: ${error}`}`)
|
||||
if (status !== "completed")
|
||||
return yield* output.invalid(`${NAME} transcript ${context.token.transcriptID} has not finished`)
|
||||
const duration = transcript.audio_duration ?? undefined
|
||||
return new TranscriptionResponse({
|
||||
text: transcript.text ?? "",
|
||||
segments: transcript.utterances?.map((utterance) => ({
|
||||
text: utterance.text,
|
||||
startSeconds: seconds(utterance.start),
|
||||
endSeconds: seconds(utterance.end),
|
||||
speaker: utterance.speaker ?? undefined,
|
||||
})),
|
||||
words: transcript.words?.map((word) => ({
|
||||
text: word.text,
|
||||
startSeconds: seconds(word.start),
|
||||
endSeconds: seconds(word.end),
|
||||
speaker: word.speaker ?? undefined,
|
||||
confidence: word.confidence ?? undefined,
|
||||
})),
|
||||
language: transcript.language_code?.toLowerCase(),
|
||||
durationSeconds: duration,
|
||||
usage: duration === undefined ? undefined : { type: "seconds", seconds: duration },
|
||||
providerMetadata: {
|
||||
assemblyai: { transcriptId: transcript.id, speechModel: transcript.speech_model_used ?? undefined },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const transcriptPath = (token: Token) => `${PATH}/${token.transcriptID}`
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, TranscriptionResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
start: { prepare, body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: transcriptPath, decode: decodeStatus },
|
||||
result: { path: transcriptPath, decode: decodeResult },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
TranscriptionModel.fromRoute<AssemblyAITranscriptionOptions, Token>(
|
||||
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
|
||||
input,
|
||||
)
|
||||
|
||||
export const AssemblyAITranscription = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -25,10 +25,8 @@ import { BedrockAuth } from "./utils/bedrock-auth.js"
|
||||
import { BedrockCache } from "./utils/bedrock-cache.js"
|
||||
import { BedrockMedia } from "./utils/bedrock-media.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
import { concatBytes } from "../utils/bytes.js"
|
||||
|
||||
const ADAPTER = "bedrock-converse"
|
||||
|
||||
@@ -108,7 +106,7 @@ type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
|
||||
const BedrockToolSpec = Schema.Struct({
|
||||
toolSpec: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
description: Schema.String,
|
||||
inputSchema: Schema.Struct({
|
||||
json: JsonObject,
|
||||
}),
|
||||
@@ -224,7 +222,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
|
||||
description: tool.description,
|
||||
inputSchema: { json: inputSchema },
|
||||
},
|
||||
})
|
||||
@@ -281,19 +279,15 @@ const removeEmptyToolInputKeys = (input: unknown): unknown => {
|
||||
)
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): BedrockToolUseBlock => ({
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: normalizeID(part.id),
|
||||
// Models can emit names that Converse rejects when replayed in history.
|
||||
name: part.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_",
|
||||
toolUseId: part.id,
|
||||
name: part.name,
|
||||
input: removeEmptyToolInputKeys(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
) {
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
|
||||
if (part.result.type === "text" || part.result.type === "error")
|
||||
return [{ text: ProviderShared.toolResultText(part) }]
|
||||
if (part.result.type === "json") return [{ json: part.result.value }]
|
||||
@@ -304,21 +298,22 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
||||
content.push({ text: item.text })
|
||||
continue
|
||||
}
|
||||
const media = yield* BedrockMedia.lower(ProviderShared.toolFileMedia(item), documentNames)
|
||||
content.push(...media)
|
||||
const media = yield* BedrockMedia.lower({
|
||||
type: "media",
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
})
|
||||
content.push(media)
|
||||
}
|
||||
return content
|
||||
})
|
||||
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
normalizeID: (id: string) => string,
|
||||
) {
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: normalizeID(part.id),
|
||||
content: yield* lowerToolResultContent(part, documentNames),
|
||||
toolUseId: part.id,
|
||||
content: yield* lowerToolResultContent(part),
|
||||
status: part.result.type === "error" ? "error" : "success",
|
||||
},
|
||||
} satisfies BedrockToolResultBlock
|
||||
@@ -329,9 +324,6 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
const documentNames = new Set<string>()
|
||||
// Mistral can reject replay IDs even when they satisfy Converse's broader ID syntax.
|
||||
const normalizeID = request.model.id.includes("mistral.") ? MistralToolID.normalizer(request) : (id: string) => id
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -355,7 +347,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(...(yield* BedrockMedia.lower(part, documentNames)))
|
||||
content.push(yield* BedrockMedia.lower(part))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -396,7 +388,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
content.push(lowerToolCall(part, normalizeID))
|
||||
content.push(lowerToolCall(part))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -408,7 +400,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
|
||||
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
|
||||
content.push(yield* lowerToolResult(part))
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
@@ -423,7 +415,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
@@ -436,7 +431,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const generation = request.generation
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints(request.model.id)
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig = (() => {
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
@@ -525,7 +520,14 @@ interface ParserState {
|
||||
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
|
||||
}
|
||||
|
||||
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => Encoding.encodeBase64(concatBytes(chunks))
|
||||
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => {
|
||||
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
|
||||
chunks.reduce((offset, chunk) => {
|
||||
bytes.set(chunk, offset)
|
||||
return offset + chunk.length
|
||||
}, 0)
|
||||
return Encoding.encodeBase64(bytes)
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: BedrockEvent) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { AIError, ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { SpeechStream } from "./utils/speech-stream.js"
|
||||
|
||||
const ADAPTER = "cartesia-speech"
|
||||
const NAME = "Cartesia"
|
||||
const PROVIDER = ProviderID.make("cartesia")
|
||||
export const DEFAULT_BASE_URL = "https://api.cartesia.ai"
|
||||
export const API_VERSION = "2026-08-14"
|
||||
export const BYTES_PATH = "/tts/bytes"
|
||||
export const SSE_PATH = "/tts/sse"
|
||||
const DEFAULT_SAMPLE_RATE = 44100
|
||||
const DEFAULT_BIT_RATE = 128000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CartesiaSpeechString<Known extends string> = Known | (string & {})
|
||||
|
||||
export type CartesiaEncoding = SpeechStream.PcmEncoding
|
||||
|
||||
export type CartesiaSpeechOptions = {
|
||||
readonly sampleRate?: 8000 | 16000 | 22050 | 24000 | 44100 | 48000
|
||||
readonly bitRate?: 32000 | 64000 | 96000 | 128000 | 192000
|
||||
readonly encoding?: CartesiaEncoding
|
||||
readonly generation_config?: {
|
||||
readonly volume?: number
|
||||
readonly emotion?: CartesiaSpeechString<"neutral" | "calm" | "angry" | "content" | "sad" | "scared">
|
||||
}
|
||||
readonly pronunciation_dict_id?: string
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<CartesiaSpeechOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Streaming event schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `phoneme_timestamps` and future record types are ignored. */
|
||||
const SseEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
data: Schema.optional(Schema.Uint8ArrayFromBase64),
|
||||
word_timestamps: Schema.optional(
|
||||
Schema.Struct({
|
||||
words: Schema.Array(Schema.String),
|
||||
start: Schema.Array(Schema.Number),
|
||||
end: Schema.Array(Schema.Number),
|
||||
}),
|
||||
),
|
||||
status_code: Schema.optional(Schema.Number),
|
||||
title: Schema.optional(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
error_code: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, SseEvent)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface State extends SpeechStream.Audio {
|
||||
readonly done: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Timestamps exist only on the SSE endpoint, so a `generate` that asks for them collects an SSE stream. */
|
||||
const usesSse = (request: MediaProtocol.Addressed<Request>) => request.mode === "stream" || request.timestamps === true
|
||||
|
||||
const CONTAINERS: Readonly<Record<string, "raw" | "wav" | "mp3">> = { pcm: "raw", wav: "wav", mp3: "mp3" }
|
||||
|
||||
const outputFormat = Effect.fn("CartesiaSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const sse = usesSse(request)
|
||||
const format = request.format ?? (sse ? "pcm" : "mp3")
|
||||
const container = CONTAINERS[format]
|
||||
if (container === undefined)
|
||||
return yield* SpeechStream.unsupportedFormat(
|
||||
PROVIDER,
|
||||
ADAPTER,
|
||||
`${NAME} supports the pcm, wav, and mp3 formats, not "${format}"`,
|
||||
)
|
||||
if (sse && container !== "raw")
|
||||
return yield* SpeechStream.unsupportedFormat(
|
||||
PROVIDER,
|
||||
ADAPTER,
|
||||
`${NAME} streams and timestamps only raw PCM; request format "pcm" instead of "${format}"`,
|
||||
)
|
||||
const sampleRate = request.providerOptions?.sampleRate ?? DEFAULT_SAMPLE_RATE
|
||||
if (container === "mp3")
|
||||
return { container, sample_rate: sampleRate, bit_rate: request.providerOptions?.bitRate ?? DEFAULT_BIT_RATE }
|
||||
return { container, encoding: request.providerOptions?.encoding ?? "pcm_s16le", sample_rate: sampleRate }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("CartesiaSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const voice = SpeechStream.voiceID(request.voice)
|
||||
if (voice === undefined)
|
||||
return yield* ProviderShared.invalidRequest(`${NAME} requires a voice id; pass it as \`voice\``)
|
||||
const { sampleRate: _sampleRate, bitRate: _bitRate, encoding: _encoding, ...native } = request.providerOptions ?? {}
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model_id: request.model.id,
|
||||
transcript: request.text,
|
||||
voice,
|
||||
output_format: yield* outputFormat(request),
|
||||
language: request.language,
|
||||
generation_config: request.speed === undefined ? undefined : { speed: request.speed },
|
||||
add_timestamps: request.timestamps === true ? true : undefined,
|
||||
},
|
||||
native,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onEvent = Effect.fn("CartesiaSpeech.onEvent")(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "chunk" && event.data !== undefined) return SpeechStream.delta(state, event.data)
|
||||
if (event.type === "timestamps" && event.word_timestamps !== undefined) {
|
||||
const words = event.word_timestamps
|
||||
return [state, SpeechStream.timestamps(words.words, words.start, words.end)] as const
|
||||
}
|
||||
if (event.type === "done") return [{ ...state, done: true }, []] as const
|
||||
if (event.type === "error")
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: `${NAME} stream failed${event.title === undefined ? "" : ` (${event.title})`}: ${event.message ?? "unknown error"}`,
|
||||
status: event.status_code,
|
||||
rawBody: frame,
|
||||
}),
|
||||
})
|
||||
return [state, []] as const
|
||||
})
|
||||
|
||||
const finish = Effect.fn("CartesiaSpeech.finish")(function* (
|
||||
state: State,
|
||||
context: MediaProtocol.ResponseContext<Request>,
|
||||
) {
|
||||
if (usesSse(context.request) && !state.done) return yield* MediaProtocol.incomplete(ADAPTER)
|
||||
const format = yield* outputFormat(context.request)
|
||||
return yield* SpeechStream.finish(
|
||||
ADAPTER,
|
||||
state,
|
||||
format.container === "raw"
|
||||
? SpeechStream.pcm(format.encoding, format.sample_rate)
|
||||
: SpeechStream.container(format.container, format.sample_rate),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["instructions"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => (usesSse(context.request) ? Framing.sse.frame(bytes) : bytes),
|
||||
initial: () => ({ chunks: [], done: false }),
|
||||
step: SpeechStream.step(onEvent),
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
SpeechModel.fromRoute<CartesiaSpeechOptions, string | Uint8Array, State>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
headers: { "Cartesia-Version": API_VERSION },
|
||||
path: ({ request }) => (usesSse(request) ? SSE_PATH : BYTES_PATH),
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const CartesiaSpeech = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
import { SpeechStream } from "./utils/speech-stream.js"
|
||||
|
||||
const ADAPTER = "deepgram-speech"
|
||||
const NAME = "Deepgram"
|
||||
const PROVIDER = ProviderID.make("deepgram")
|
||||
export const DEFAULT_BASE_URL = "https://api.deepgram.com"
|
||||
export const PATH = "/v1/speak"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DeepgramSpeechString<Known extends string> = Known | (string & {})
|
||||
|
||||
export type DeepgramEncoding = DeepgramSpeechString<"linear16" | "mulaw" | "alaw" | "mp3" | "opus" | "flac" | "aac">
|
||||
|
||||
export type DeepgramSpeechOptions = {
|
||||
readonly encoding?: DeepgramEncoding
|
||||
readonly container?: DeepgramSpeechString<"wav" | "ogg" | "none">
|
||||
readonly sampleRate?: number
|
||||
readonly bitRate?: number
|
||||
readonly mip_opt_out?: boolean
|
||||
readonly tag?: string
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<DeepgramSpeechOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type State = SpeechStream.Audio
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FORMATS: Readonly<Record<string, { readonly encoding: string; readonly container?: string }>> = {
|
||||
mp3: { encoding: "mp3" },
|
||||
wav: { encoding: "linear16", container: "wav" },
|
||||
pcm: { encoding: "linear16", container: "none" },
|
||||
opus: { encoding: "opus" },
|
||||
flac: { encoding: "flac" },
|
||||
aac: { encoding: "aac" },
|
||||
}
|
||||
|
||||
const audioFormat = (request: Request) => {
|
||||
const format = request.format === undefined ? undefined : FORMATS[request.format]
|
||||
return {
|
||||
encoding: request.providerOptions?.encoding ?? format?.encoding,
|
||||
container: request.providerOptions?.container ?? format?.container,
|
||||
}
|
||||
}
|
||||
|
||||
const queryParameters = (request: Request) => {
|
||||
const { encoding: _encoding, container: _container, sampleRate, bitRate, ...native } = request.providerOptions ?? {}
|
||||
return MediaInput.query(ADAPTER, {
|
||||
...native,
|
||||
model: request.model.id,
|
||||
...audioFormat(request),
|
||||
sample_rate: sampleRate,
|
||||
bit_rate: bitRate,
|
||||
speed: request.speed,
|
||||
})
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request: Request) {
|
||||
if (
|
||||
request.format !== undefined &&
|
||||
FORMATS[request.format] === undefined &&
|
||||
request.providerOptions?.encoding === undefined
|
||||
)
|
||||
return yield* SpeechStream.unsupportedFormat(
|
||||
PROVIDER,
|
||||
ADAPTER,
|
||||
`${NAME} has no encoding for format "${request.format}"; pass providerOptions.encoding`,
|
||||
)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords({ text: request.text }, request.http?.body) ?? {},
|
||||
yield* queryParameters(request),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HEADERLESS_ENCODINGS: Readonly<Record<string, SpeechStream.PcmEncoding>> = {
|
||||
linear16: "pcm_s16le",
|
||||
mulaw: "pcm_mulaw",
|
||||
alaw: "pcm_alaw",
|
||||
}
|
||||
|
||||
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
|
||||
const headers = context.http.headers
|
||||
const mediaType = headers["content-type"]
|
||||
const format = audioFormat(context.request)
|
||||
const encoding = HEADERLESS_ENCODINGS[format.encoding ?? ""]
|
||||
const requestID = headers["dg-request-id"]
|
||||
const modelName = headers["dg-model-name"]
|
||||
return SpeechStream.finish(ADAPTER, state, {
|
||||
...(format.container === "none" && encoding !== undefined
|
||||
? SpeechStream.pcm(encoding, SpeechStream.sampleRate(mediaType), mediaType)
|
||||
: // Deepgram's default encoding is MP3; WAV is a container around any encoding.
|
||||
{ mediaType, info: { format: format.container === "wav" ? "wav" : (format.encoding ?? "mp3") } }),
|
||||
usage: SpeechStream.headerUsage("characters", headers["dg-char-count"]),
|
||||
providerMetadata:
|
||||
requestID === undefined && modelName === undefined
|
||||
? undefined
|
||||
: { deepgram: { requestId: requestID, modelName } },
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["voice", "language", "instructions", "timestamps"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes) => bytes,
|
||||
initial: () => ({ chunks: [] }),
|
||||
step: (state, frame) => Effect.succeed(SpeechStream.delta(state, frame)),
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
SpeechModel.fromRoute<DeepgramSpeechOptions, Uint8Array, State>(
|
||||
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
|
||||
input,
|
||||
)
|
||||
|
||||
export const DeepgramSpeech = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
const ADAPTER = "deepgram-transcription"
|
||||
const NAME = "Deepgram"
|
||||
const PROVIDER = ProviderID.make("deepgram")
|
||||
export const DEFAULT_BASE_URL = "https://api.deepgram.com"
|
||||
export const PATH = "/v1/listen"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DeepgramTranscriptionOptions = {
|
||||
readonly smart_format?: boolean
|
||||
readonly punctuate?: boolean
|
||||
readonly paragraphs?: boolean
|
||||
readonly utterances?: boolean
|
||||
readonly detect_language?: boolean | ReadonlyArray<string>
|
||||
readonly keyterm?: ReadonlyArray<string>
|
||||
readonly diarize_model?: "latest" | "v1" | "v2" | (string & {})
|
||||
readonly filler_words?: boolean
|
||||
readonly numerals?: boolean
|
||||
readonly mip_opt_out?: boolean
|
||||
readonly tag?: string | ReadonlyArray<string>
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = TranscriptionRequestFor<DeepgramTranscriptionOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Word = Schema.Struct({
|
||||
word: Schema.String,
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
confidence: Schema.optional(Schema.Number),
|
||||
speaker: Schema.optional(Schema.Number),
|
||||
punctuated_word: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ListenResponse = Schema.Struct({
|
||||
metadata: Schema.optional(
|
||||
Schema.Struct({ request_id: Schema.optional(Schema.String), duration: Schema.optional(Schema.Number) }),
|
||||
),
|
||||
results: Schema.Struct({
|
||||
channels: Schema.Array(
|
||||
Schema.Struct({
|
||||
alternatives: Schema.optional(
|
||||
Schema.Array(Schema.Struct({ transcript: Schema.String, words: Schema.optional(Schema.Array(Word)) })),
|
||||
),
|
||||
detected_language: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
utterances: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
transcript: Schema.String,
|
||||
speaker: Schema.optional(Schema.Number),
|
||||
words: Schema.optional(Schema.Array(Word)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const query = (request: Request) =>
|
||||
MediaInput.query(
|
||||
ADAPTER,
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
smart_format: true,
|
||||
language: request.language,
|
||||
// Deepgram assumes English unless asked to detect, unlike the other routes' auto-detection.
|
||||
detect_language: request.language === undefined ? true : undefined,
|
||||
// `diarize=true` is deprecated in favor of choosing a diarization model.
|
||||
diarize_model: request.diarize === true ? "latest" : undefined,
|
||||
utterances: request.diarize === true || request.timestamps === "segment" ? true : undefined,
|
||||
},
|
||||
request.providerOptions,
|
||||
) ?? {},
|
||||
)
|
||||
|
||||
const fromRequest = Effect.fn("DeepgramTranscription.fromRequest")(function* (request: Request) {
|
||||
const url = ProviderShared.mediaUrl(request.audio)
|
||||
if (url !== undefined)
|
||||
return MediaProtocol.json(mergeJsonRecords({ url }, request.http?.body) ?? {}, yield* query(request))
|
||||
if (request.http?.body !== undefined)
|
||||
return yield* ProviderShared.invalidRequest(`${NAME} sends inline audio as the raw body, so http.body cannot apply`)
|
||||
const audio = yield* MediaInput.inlineBytes(ADAPTER, request.audio)
|
||||
return MediaProtocol.binary(audio, request.audio.mediaType, yield* query(request))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeListen = MediaProtocol.decodeJson(ADAPTER, NAME, ListenResponse)
|
||||
|
||||
const speaker = (value: number | undefined) => (value === undefined ? undefined : String(value))
|
||||
|
||||
const wordText = (word: typeof Word.Type) => word.punctuated_word ?? word.word
|
||||
|
||||
// Utterances split on pauses, not speakers: the v2 diarizer labels a whole utterance with one speaker even when its
|
||||
// words change speaker, so segments split each utterance at speaker changes.
|
||||
const speakerTurns = (words: ReadonlyArray<typeof Word.Type>) =>
|
||||
words.reduce<Array<Array<typeof Word.Type>>>((turns, word) => {
|
||||
const last = turns.at(-1)
|
||||
if (last === undefined || last[0].speaker !== word.speaker) return [...turns, [word]]
|
||||
last.push(word)
|
||||
return turns
|
||||
}, [])
|
||||
|
||||
const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const output = yield* decodeListen(response)
|
||||
const channel = output.value.results.channels[0]
|
||||
const alternative = channel?.alternatives?.[0]
|
||||
if (alternative === undefined) return yield* output.invalid(`${NAME} returned no transcript`)
|
||||
const duration = output.value.metadata?.duration
|
||||
const requestID = output.value.metadata?.request_id
|
||||
return new TranscriptionResponse({
|
||||
text: alternative.transcript,
|
||||
segments: output.value.results.utterances?.flatMap((utterance) =>
|
||||
utterance.words === undefined || utterance.words.length === 0
|
||||
? [
|
||||
{
|
||||
text: utterance.transcript,
|
||||
startSeconds: utterance.start,
|
||||
endSeconds: utterance.end,
|
||||
speaker: speaker(utterance.speaker),
|
||||
},
|
||||
]
|
||||
: speakerTurns(utterance.words).map((turn) => ({
|
||||
text: turn.map(wordText).join(" "),
|
||||
startSeconds: turn[0].start,
|
||||
endSeconds: turn[turn.length - 1].end,
|
||||
speaker: speaker(turn[0].speaker),
|
||||
})),
|
||||
),
|
||||
words: alternative.words?.map((word) => ({
|
||||
text: wordText(word),
|
||||
startSeconds: word.start,
|
||||
endSeconds: word.end,
|
||||
speaker: speaker(word.speaker),
|
||||
confidence: word.confidence,
|
||||
})),
|
||||
language: channel?.detected_language?.toLowerCase(),
|
||||
durationSeconds: duration,
|
||||
usage: duration === undefined ? undefined : { type: "seconds", seconds: duration },
|
||||
providerMetadata: requestID === undefined ? undefined : { deepgram: { requestId: requestID } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, TranscriptionResponse>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["prompt", "speakers"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
TranscriptionModel.fromRoute<DeepgramTranscriptionOptions>(
|
||||
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
|
||||
input,
|
||||
)
|
||||
|
||||
export const DeepgramTranscription = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,218 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { SpeechStream } from "./utils/speech-stream.js"
|
||||
|
||||
const ADAPTER = "elevenlabs-speech"
|
||||
const NAME = "ElevenLabs"
|
||||
const PROVIDER = ProviderID.make("elevenlabs")
|
||||
export const DEFAULT_BASE_URL = "https://api.elevenlabs.io"
|
||||
export const PATH = "/v1/text-to-speech"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ElevenLabsSpeechString<Known extends string> = Known | (string & {})
|
||||
|
||||
export type ElevenLabsOutputFormat = ElevenLabsSpeechString<
|
||||
| "mp3_22050_32"
|
||||
| "mp3_24000_48"
|
||||
| "mp3_44100_32"
|
||||
| "mp3_44100_64"
|
||||
| "mp3_44100_96"
|
||||
| "mp3_44100_128"
|
||||
| "mp3_44100_192"
|
||||
| "pcm_8000"
|
||||
| "pcm_16000"
|
||||
| "pcm_22050"
|
||||
| "pcm_24000"
|
||||
| "pcm_32000"
|
||||
| "pcm_44100"
|
||||
| "pcm_48000"
|
||||
| "wav_8000"
|
||||
| "wav_16000"
|
||||
| "wav_22050"
|
||||
| "wav_24000"
|
||||
| "wav_32000"
|
||||
| "wav_44100"
|
||||
| "wav_48000"
|
||||
| "ulaw_8000"
|
||||
| "alaw_8000"
|
||||
| "opus_48000_32"
|
||||
| "opus_48000_64"
|
||||
| "opus_48000_96"
|
||||
| "opus_48000_128"
|
||||
| "opus_48000_192"
|
||||
>
|
||||
|
||||
export type ElevenLabsSpeechOptions = {
|
||||
readonly outputFormat?: ElevenLabsOutputFormat
|
||||
readonly voice_settings?: {
|
||||
readonly stability?: number
|
||||
readonly similarity_boost?: number
|
||||
readonly style?: number
|
||||
readonly use_speaker_boost?: boolean
|
||||
}
|
||||
readonly seed?: number
|
||||
readonly apply_text_normalization?: ElevenLabsSpeechString<"auto" | "on" | "off">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<ElevenLabsSpeechOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Streaming event schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Alignment = Schema.Struct({
|
||||
characters: Schema.Array(Schema.String),
|
||||
character_start_times_seconds: Schema.Array(Schema.Number),
|
||||
character_end_times_seconds: Schema.Array(Schema.Number),
|
||||
})
|
||||
|
||||
const TimestampedAudio = Schema.Struct({
|
||||
audio_base64: Schema.Uint8ArrayFromBase64,
|
||||
alignment: optionalNull(Alignment),
|
||||
})
|
||||
|
||||
const decodeRecord = MediaProtocol.decodeFrame(ADAPTER, NAME, TimestampedAudio)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type State = SpeechStream.Audio
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OUTPUT_FORMATS: Readonly<Record<string, string>> = {
|
||||
mp3: "mp3_44100_128",
|
||||
pcm: "pcm_24000",
|
||||
wav: "wav_24000",
|
||||
opus: "opus_48000_64",
|
||||
}
|
||||
|
||||
/** WAV is served only by the non-streaming endpoints. */
|
||||
const outputFormat = Effect.fn("ElevenLabsSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const format = request.providerOptions?.outputFormat ?? OUTPUT_FORMATS[request.format ?? "mp3"]
|
||||
if (format === undefined)
|
||||
return yield* SpeechStream.unsupportedFormat(
|
||||
PROVIDER,
|
||||
ADAPTER,
|
||||
`${NAME} has no default output format for "${request.format}"; pass providerOptions.outputFormat`,
|
||||
)
|
||||
if (request.mode === "stream" && format.startsWith("wav_"))
|
||||
return yield* SpeechStream.unsupportedFormat(
|
||||
PROVIDER,
|
||||
ADAPTER,
|
||||
`${NAME} streams mp3, pcm, opus, ulaw, and alaw but not "${format}"; use generate for WAV`,
|
||||
)
|
||||
return format
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("ElevenLabsSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
if (request.voice === undefined)
|
||||
return yield* ProviderShared.invalidRequest(`${NAME} requires a voice id; pass it as \`voice\``)
|
||||
const { outputFormat: _outputFormat, ...native } = request.providerOptions ?? {}
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
text: request.text,
|
||||
model_id: request.model.id,
|
||||
language_code: request.language,
|
||||
voice_settings: request.speed === undefined ? undefined : { speed: request.speed },
|
||||
},
|
||||
native,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
{ output_format: yield* outputFormat(request) },
|
||||
)
|
||||
})
|
||||
|
||||
const path = (request: MediaProtocol.Addressed<Request>) =>
|
||||
`${PATH}/${encodeURIComponent(SpeechStream.voiceID(request.voice) ?? "")}${request.mode === "stream" ? "/stream" : ""}${
|
||||
request.timestamps === true ? "/with-timestamps" : ""
|
||||
}`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onRecord = Effect.fn("ElevenLabsSpeech.onRecord")(function* (state: State, frame: string) {
|
||||
const record = yield* decodeRecord(frame)
|
||||
const [next, events] = SpeechStream.delta(state, record.audio_base64)
|
||||
const alignment = record.alignment
|
||||
if (!alignment) return [next, events] as const
|
||||
return [
|
||||
next,
|
||||
[
|
||||
...events,
|
||||
...SpeechStream.timestamps(
|
||||
alignment.characters,
|
||||
alignment.character_start_times_seconds,
|
||||
alignment.character_end_times_seconds,
|
||||
),
|
||||
],
|
||||
] as const
|
||||
})
|
||||
|
||||
const PCM_CODECS: Readonly<Record<string, SpeechStream.PcmEncoding>> = {
|
||||
pcm: "pcm_s16le",
|
||||
ulaw: "pcm_mulaw",
|
||||
alaw: "pcm_alaw",
|
||||
}
|
||||
|
||||
const describeOutput = (format: string) => {
|
||||
const [codec = format, rate] = format.split("_")
|
||||
const sampleRate = rate === undefined ? undefined : Number(rate)
|
||||
const encoding = PCM_CODECS[codec]
|
||||
return encoding === undefined ? SpeechStream.container(codec, sampleRate) : SpeechStream.pcm(encoding, sampleRate)
|
||||
}
|
||||
|
||||
const finish = Effect.fn("ElevenLabsSpeech.finish")(function* (
|
||||
state: State,
|
||||
context: MediaProtocol.ResponseContext<Request>,
|
||||
) {
|
||||
const requestID = context.http.headers["request-id"]
|
||||
return yield* SpeechStream.finish(ADAPTER, state, {
|
||||
...describeOutput(yield* outputFormat(context.request)),
|
||||
// `character-cost` is billed credits, not a character count (3 for 20 characters on `eleven_flash_v2_5`).
|
||||
usage: SpeechStream.headerUsage("credits", context.http.headers["character-cost"]),
|
||||
providerMetadata: requestID === undefined ? undefined : { elevenlabs: { requestId: requestID } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["instructions"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => {
|
||||
if (context.request.timestamps !== true) return bytes
|
||||
return context.request.mode === "stream" ? Framing.lines.frame(bytes) : Framing.document.frame(bytes)
|
||||
},
|
||||
initial: () => ({ chunks: [] }),
|
||||
step: SpeechStream.step(onRecord),
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
SpeechModel.fromRoute<ElevenLabsSpeechOptions, string | Uint8Array, State>(
|
||||
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: ({ request }) => path(request) },
|
||||
input,
|
||||
)
|
||||
|
||||
export const ElevenLabsSpeech = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,198 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
|
||||
const ADAPTER = "fal-video"
|
||||
const NAME = "fal Video"
|
||||
const PROVIDER = ProviderID.make("fal")
|
||||
export const DEFAULT_BASE_URL = "https://queue.fal.run"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FalVideoString<Known extends string> = Known | (string & {})
|
||||
|
||||
/**
|
||||
* Provider-native input. fal video endpoints are model-specific: `duration` is a string enum whose values differ per
|
||||
* model (`"8s"` for Veo, `"5"` for Kling), and last-frame fields are named per model (`end_image_url`,
|
||||
* `last_frame_url`, `tail_image_url`), so those pass through here instead of lowering from common fields.
|
||||
*/
|
||||
export type FalVideoOptions = {
|
||||
readonly duration?: FalVideoString<"4s" | "6s" | "8s" | "5" | "10">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<FalVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** fal hands back absolute follow-up URLs on submit; they are authoritative for status, result, and cancel. */
|
||||
export const Token = Schema.Struct({
|
||||
requestID: Schema.String,
|
||||
statusURL: Schema.String,
|
||||
responseURL: Schema.String,
|
||||
cancelURL: Schema.String,
|
||||
})
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({
|
||||
request_id: Schema.String,
|
||||
status_url: Schema.String,
|
||||
response_url: Schema.String,
|
||||
cancel_url: Schema.String,
|
||||
queue_position: optionalNull(Schema.Number),
|
||||
})
|
||||
|
||||
const QueueStatus = Schema.Struct({
|
||||
status: Schema.String,
|
||||
queue_position: optionalNull(Schema.Number),
|
||||
error: optionalNull(Schema.Unknown),
|
||||
})
|
||||
|
||||
const QueueResult = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
video: Schema.Struct({
|
||||
url: Schema.String,
|
||||
content_type: optionalNull(Schema.String),
|
||||
file_name: optionalNull(Schema.String),
|
||||
file_size: optionalNull(Schema.Number),
|
||||
}),
|
||||
seed: optionalNull(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const STATUS = {
|
||||
IN_QUEUE: "queued",
|
||||
IN_PROGRESS: "running",
|
||||
COMPLETED: "completed",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fal accepts public URLs and data URIs; there is no provider file handle to forward.
|
||||
const mediaUrl = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((reference) => reference.value))
|
||||
|
||||
const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request: Request) {
|
||||
if (request.frames?.last !== undefined)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "video.frames.last",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${NAME} names the last frame per model; pass it through providerOptions (e.g. end_image_url) instead of frames.last`,
|
||||
})
|
||||
const imageUrl = request.frames?.first === undefined ? undefined : yield* mediaUrl(request.frames.first)
|
||||
const videoUrl = request.video === undefined ? undefined : yield* mediaUrl(request.video)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
prompt: request.prompt,
|
||||
negative_prompt: request.negativePrompt,
|
||||
seed: request.seed,
|
||||
aspect_ratio: request.aspectRatio,
|
||||
resolution: request.resolution,
|
||||
generate_audio: request.audio,
|
||||
image_url: imageUrl,
|
||||
video_url: videoUrl,
|
||||
},
|
||||
request.providerOptions,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: {
|
||||
requestID: value.request_id,
|
||||
statusURL: value.status_url,
|
||||
responseURL: value.response_url,
|
||||
cancelURL: value.cancel_url,
|
||||
},
|
||||
snapshot: { id: value.request_id, status: "queued", position: value.queue_position ?? undefined },
|
||||
}))
|
||||
|
||||
const decodeQueueStatus = MediaProtocol.decodeJson(ADAPTER, NAME, QueueStatus)
|
||||
const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult)
|
||||
|
||||
const decodeStatus = Effect.fn("FalVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeQueueStatus(response)
|
||||
const decoded = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
|
||||
// fal reports request failures as COMPLETED with an `error`; the response endpoint carries the details.
|
||||
const failed = status === "completed" && decoded.error !== undefined && decoded.error !== null
|
||||
return {
|
||||
id: context.token.requestID,
|
||||
status: failed ? "failed" : status,
|
||||
position: status === "queued" ? (decoded.queue_position ?? undefined) : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeQueueResult(response)
|
||||
const { video, seed, ...rest } = output.value
|
||||
return new VideoResponse({
|
||||
videos: [Media.url(video.url, { mediaType: video.content_type ?? "video/mp4" })],
|
||||
providerMetadata: {
|
||||
fal: {
|
||||
requestId: context.token.requestID,
|
||||
seed: seed ?? undefined,
|
||||
fileName: video.file_name ?? undefined,
|
||||
fileSize: video.file_size ?? undefined,
|
||||
...rest,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
unsupported: ["n", "durationSeconds", "references"],
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: (token) => token.statusURL, decode: decodeStatus },
|
||||
result: { path: (token) => token.responseURL, decode: decodeResult },
|
||||
cancel: { method: "PUT", path: (token) => token.cancelURL },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<FalVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => `/${request.model.id}`,
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const FalVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
@@ -14,15 +14,12 @@ import {
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { Media } from "../media.js"
|
||||
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -53,8 +50,35 @@ const omitsFunctionCallIds = (modelID: string) => {
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
// =============================================================================
|
||||
@@ -76,18 +100,9 @@ const GeminiInlineDataPart = Schema.Struct({
|
||||
mimeType: Schema.String,
|
||||
data: Schema.String,
|
||||
}),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
})
|
||||
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
/** Gemini Files API reference; the only remote input Gemini accepts. */
|
||||
const GeminiFileDataPart = Schema.Struct({
|
||||
fileData: Schema.Struct({
|
||||
mimeType: Schema.String,
|
||||
fileUri: Schema.String,
|
||||
}),
|
||||
})
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: optionalNull(Schema.String),
|
||||
@@ -109,7 +124,6 @@ const GeminiFunctionResponsePart = Schema.Struct({
|
||||
const GeminiContentPart = Schema.Union([
|
||||
GeminiTextPart,
|
||||
GeminiInlineDataPart,
|
||||
GeminiFileDataPart,
|
||||
GeminiFunctionCallPart,
|
||||
GeminiFunctionResponsePart,
|
||||
])
|
||||
@@ -147,50 +161,17 @@ const GeminiToolConfig = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(GeminiThinkingLevel),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: knownString<
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
>(),
|
||||
threshold: knownString<
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
>(),
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
|
||||
// object that omits `includeThoughts` asks for thoughts.
|
||||
const GeminiThinkingConfigInput = Schema.Struct({
|
||||
thinkingBudget: lenient(Schema.Number),
|
||||
includeThoughts: lenient(Schema.Boolean),
|
||||
thinkingLevel: lenient(GeminiThinkingLevel),
|
||||
})
|
||||
const Options = Schema.Struct({
|
||||
cachedContent: lenient(Schema.String),
|
||||
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
|
||||
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
|
||||
thinkingConfig: lenient(GeminiThinkingConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
maxOutputTokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
@@ -306,9 +287,10 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
|
||||
})
|
||||
|
||||
const lowerContentPart = Effect.fn("Gemini.lowerContentPart")(function* (part: TextPart | MediaPart) {
|
||||
const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) {
|
||||
if (part.type === "text") return { text: part.text }
|
||||
return yield* GeminiGenerateContent.mediaPart("Gemini", part.media)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return { inlineData: { mimeType: media.mime, data: media.base64 } }
|
||||
})
|
||||
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
@@ -355,7 +337,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text", "media"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"])
|
||||
parts.push(yield* lowerContentPart(part))
|
||||
parts.push(yield* lowerUserPart(part))
|
||||
}
|
||||
contents.push({ role: "user", parts })
|
||||
continue
|
||||
@@ -366,23 +348,12 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
|
||||
let hasSignedToolCall = false
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call", "media"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", [
|
||||
"text",
|
||||
"reasoning",
|
||||
"tool-call",
|
||||
"media",
|
||||
])
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||
if (part.type === "text") {
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey) })
|
||||
continue
|
||||
}
|
||||
// Generated images replay as model-role inline data so multi-turn image editing keeps the prior output.
|
||||
if (part.type === "media") {
|
||||
const lowered = yield* lowerContentPart(part)
|
||||
parts.push({ ...lowered, thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
parts.push({
|
||||
text: part.text,
|
||||
@@ -432,7 +403,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const media: GeminiInlineDataPart[] = []
|
||||
for (const item of content) {
|
||||
if (item.type === "text") continue
|
||||
const value = yield* ProviderShared.requireInlineMedia("Gemini", ProviderShared.toolFileMedia(item).media)
|
||||
const value = ProviderShared.normalizeToolFile(item)
|
||||
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||
}
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
@@ -460,11 +431,44 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
return contents
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const input = request.providerOptions
|
||||
const value = input?.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const hasTools = flattened.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const options = resolveOptions(request)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
@@ -475,10 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig:
|
||||
options.thinkingConfig === undefined
|
||||
? undefined
|
||||
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -638,14 +639,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
: state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(
|
||||
state.route,
|
||||
`Gemini stopped with ${candidate.finishReason}`,
|
||||
ProviderShared.encodeJson(event),
|
||||
),
|
||||
)
|
||||
if (!candidate?.content)
|
||||
return Effect.succeed([
|
||||
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
|
||||
@@ -684,19 +677,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
// each block kind must retain the signature attached to its own parts.
|
||||
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
|
||||
else if (signature !== undefined && "text" in part) textSignature = signature
|
||||
// Image-capable Gemini models return generated images as inline data parts; surface them as first-class output.
|
||||
if ("inlineData" in part) {
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.media({
|
||||
media: Media.base64(part.inlineData.data, part.inlineData.mimeType),
|
||||
providerMetadata: signature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature })
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
if (part.thought) {
|
||||
if (textId !== undefined) {
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
GeneratedImage,
|
||||
ImageModel,
|
||||
ImageResponse,
|
||||
type ImageInput,
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import { AIError, Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
const ADAPTER = "google-images"
|
||||
const NAME = "Google Images"
|
||||
const PROVIDER = ProviderID.make("google")
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GoogleImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
/** Provider-native options. Common fields (`aspectRatio`, `seed`, `images`) live on the request. */
|
||||
export type GoogleImageOptions = {
|
||||
readonly aspectRatio?: GoogleImageString<
|
||||
"1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"
|
||||
>
|
||||
readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
|
||||
readonly seed?: number
|
||||
readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
|
||||
readonly includeThoughts?: boolean
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = ImageRequestFor<GoogleImageOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response schema
|
||||
// ---------------------------------------------------------------------------
|
||||
export type GoogleImageBody = Record<string, unknown> & {
|
||||
readonly contents: ReadonlyArray<{
|
||||
readonly role: "user"
|
||||
readonly parts: ReadonlyArray<Record<string, unknown>>
|
||||
}>
|
||||
readonly generationConfig: Record<string, unknown>
|
||||
}
|
||||
|
||||
const GoogleUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -81,20 +85,30 @@ const GoogleImageResponse = Schema.Struct({
|
||||
promptFeedback: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const generationConfig = (request: Request) => {
|
||||
const { imageSize, thinkingLevel, includeThoughts, ...native } = request.providerOptions ?? {}
|
||||
const imageConfig = { aspectRatio: request.aspectRatio, imageSize }
|
||||
const thinkingConfig = { thinkingLevel, includeThoughts }
|
||||
const nativeOptions = (options: GoogleImageOptions | undefined) => {
|
||||
const { aspectRatio, imageSize, seed, thinkingLevel, includeThoughts, ...native } = options ?? {}
|
||||
const image = {
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
}
|
||||
const thinkingConfig = {
|
||||
thinkingLevel,
|
||||
includeThoughts,
|
||||
}
|
||||
return (
|
||||
mergeJsonRecords(
|
||||
{
|
||||
responseModalities: ["IMAGE"],
|
||||
imageConfig: Object.values(imageConfig).some((value) => value !== undefined) ? imageConfig : undefined,
|
||||
seed: request.seed,
|
||||
imageConfig: Object.values(image).some((value) => value !== undefined) ? image : undefined,
|
||||
seed,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((value) => value !== undefined) ? thinkingConfig : undefined,
|
||||
},
|
||||
native,
|
||||
@@ -102,189 +116,176 @@ const generationConfig = (request: Request) => {
|
||||
)
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("GoogleImages.fromRequest")(function* (request: Request) {
|
||||
if (request.n !== undefined && request.n > 1)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "image.n",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${NAME} generates one image per request; call it once per image instead of n=${request.n}`,
|
||||
})
|
||||
const parts = yield* Effect.forEach(request.images ?? [], (image) => GeminiGenerateContent.mediaPart(NAME, image))
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
contents: [{ role: "user", parts: [{ text: request.prompt }, ...parts] }],
|
||||
generationConfig: generationConfig(request),
|
||||
},
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeResponse = Effect.fn("GoogleImages.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, GoogleImageResponse)(response)
|
||||
const decoded = output.value
|
||||
const candidates = decoded.candidates ?? []
|
||||
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||
index: candidate.index ?? candidateIndex,
|
||||
finishReason: candidate.finishReason,
|
||||
finishMessage: candidate.finishMessage,
|
||||
safetyRatings: candidate.safetyRatings,
|
||||
citationMetadata: candidate.citationMetadata,
|
||||
groundingMetadata: candidate.groundingMetadata,
|
||||
parts: (candidate.content?.parts ?? []).map((part) =>
|
||||
part.inlineData === undefined
|
||||
? { type: "text", text: part.text, thought: part.thought, thoughtSignature: part.thoughtSignature }
|
||||
: {
|
||||
type: "inlineData",
|
||||
mediaType: part.inlineData.mimeType,
|
||||
thought: part.thought,
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
},
|
||||
),
|
||||
}))
|
||||
// Thought parts are drafts; only non-thought inline data is a final image.
|
||||
const encoded = candidates.flatMap((candidate, candidateIndex) =>
|
||||
(candidate.content?.parts ?? []).flatMap((part, partIndex) =>
|
||||
part.inlineData === undefined || part.thought === true
|
||||
? []
|
||||
: [
|
||||
{
|
||||
candidate,
|
||||
candidateIndex,
|
||||
partIndex,
|
||||
inlineData: part.inlineData,
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
const images = yield* Effect.forEach(encoded, (item) =>
|
||||
MediaInput.decodedAsset(
|
||||
output.invalid,
|
||||
`${NAME} candidate ${item.candidateIndex} part ${item.partIndex}`,
|
||||
item.inlineData.data,
|
||||
item.inlineData.mimeType,
|
||||
{
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<GoogleImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
|
||||
const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
|
||||
generationConfig: nativeOptions(request.options),
|
||||
},
|
||||
http?.body,
|
||||
) as GoogleImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const url = applyQuery(
|
||||
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
|
||||
http?.query,
|
||||
)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "Google Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(GoogleImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("Google Images returned an invalid response", cause)),
|
||||
)
|
||||
const candidates = decoded.candidates ?? []
|
||||
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||
index: candidate.index ?? candidateIndex,
|
||||
finishReason: candidate.finishReason,
|
||||
finishMessage: candidate.finishMessage,
|
||||
safetyRatings: candidate.safetyRatings,
|
||||
citationMetadata: candidate.citationMetadata,
|
||||
groundingMetadata: candidate.groundingMetadata,
|
||||
parts: (candidate.content?.parts ?? []).map((part) =>
|
||||
part.inlineData === undefined
|
||||
? {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
thought: part.thought,
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
}
|
||||
: {
|
||||
type: "inlineData",
|
||||
mediaType: part.inlineData.mimeType,
|
||||
thought: part.thought,
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
},
|
||||
),
|
||||
}))
|
||||
const encoded = candidates.flatMap((candidate, candidateIndex) =>
|
||||
(candidate.content?.parts ?? []).flatMap((part, partIndex) =>
|
||||
part.inlineData === undefined || part.thought === true
|
||||
? []
|
||||
: [{ candidate, candidateIndex, partIndex, inlineData: part.inlineData }],
|
||||
),
|
||||
)
|
||||
const images = yield* Effect.forEach(encoded, (item) =>
|
||||
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
output.invalid(
|
||||
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
mediaType: item.inlineData.mimeType,
|
||||
data,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
candidateIndex: item.candidate.index ?? item.candidateIndex,
|
||||
partIndex: item.partIndex,
|
||||
finishReason: item.candidate.finishReason,
|
||||
safetyRatings: item.candidate.safetyRatings,
|
||||
citationMetadata: item.candidate.citationMetadata,
|
||||
groundingMetadata: item.candidate.groundingMetadata,
|
||||
thoughtSignature: item.candidate.content?.parts[item.partIndex]?.thoughtSignature,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (images.length === 0) {
|
||||
const finishReasons = candidates.flatMap((candidate) =>
|
||||
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||
)
|
||||
return yield* output.invalid(
|
||||
`Google Images returned no final images${
|
||||
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||
}; inspect body for prompt feedback and candidate details`,
|
||||
)
|
||||
}
|
||||
const usage = decoded.usageMetadata
|
||||
const outputTokens =
|
||||
usage?.candidatesTokenCount === undefined
|
||||
? undefined
|
||||
: usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
usage === undefined
|
||||
? undefined
|
||||
: new Usage({
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: ProviderShared.subtractTokens(
|
||||
usage.promptTokenCount,
|
||||
usage.cachedContentTokenCount,
|
||||
),
|
||||
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
}),
|
||||
providerMetadata: {
|
||||
google: {
|
||||
candidateIndex: item.candidate.index ?? item.candidateIndex,
|
||||
partIndex: item.partIndex,
|
||||
finishReason: item.candidate.finishReason,
|
||||
safetyRatings: item.candidate.safetyRatings,
|
||||
citationMetadata: item.candidate.citationMetadata,
|
||||
groundingMetadata: item.candidate.groundingMetadata,
|
||||
thoughtSignature: item.thoughtSignature,
|
||||
modelVersion: decoded.modelVersion,
|
||||
responseId: decoded.responseId,
|
||||
promptFeedback: decoded.promptFeedback,
|
||||
candidates: candidateMetadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
if (images.length === 0) {
|
||||
const finishReasons = candidates.flatMap((candidate) =>
|
||||
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||
)
|
||||
return yield* output.invalid(
|
||||
`${NAME} returned no final images${
|
||||
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||
}; inspect body for prompt feedback and candidate details`,
|
||||
)
|
||||
})
|
||||
}),
|
||||
}
|
||||
// Candidates that stopped for a safety or policy reason are partial results, not a silent drop.
|
||||
const notices = [
|
||||
...(decoded.promptFeedback === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "filtered" as const,
|
||||
message: `${NAME} reported prompt feedback`,
|
||||
providerMetadata: { google: { promptFeedback: decoded.promptFeedback } },
|
||||
},
|
||||
]),
|
||||
...candidates.flatMap((candidate, index) =>
|
||||
candidate.finishReason === undefined || candidate.finishReason === "STOP"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "filtered" as const,
|
||||
message: `${NAME} candidate ${candidate.index ?? index} finished with ${candidate.finishReason}${
|
||||
candidate.finishMessage === undefined ? "" : `: ${candidate.finishMessage}`
|
||||
}`,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
candidateIndex: candidate.index ?? index,
|
||||
finishReason: candidate.finishReason,
|
||||
finishMessage: candidate.finishMessage,
|
||||
safetyRatings: candidate.safetyRatings,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
const usage = decoded.usageMetadata
|
||||
const outputTokens =
|
||||
usage?.candidatesTokenCount === undefined ? undefined : usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
|
||||
return new ImageResponse({
|
||||
images,
|
||||
notices: notices.length === 0 ? undefined : notices,
|
||||
usage:
|
||||
usage === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: "tokens",
|
||||
input: usage.promptTokenCount,
|
||||
output: outputTokens,
|
||||
total: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
details: {
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||
google: usage,
|
||||
},
|
||||
},
|
||||
providerMetadata: {
|
||||
google: {
|
||||
modelVersion: decoded.modelVersion,
|
||||
responseId: decoded.responseId,
|
||||
promptFeedback: decoded.promptFeedback,
|
||||
candidates: candidateMetadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["mask", "size", "format"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
ImageModel.fromRoute<GoogleImageOptions>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => `/models/${request.model.id}:generateContent`,
|
||||
},
|
||||
input,
|
||||
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, AIError> => {
|
||||
if (image.type === "bytes")
|
||||
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
|
||||
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
|
||||
if (image.type === "url")
|
||||
return ImageInputs.decodeDataUrl(image.url).pipe(
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid(
|
||||
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
|
||||
),
|
||||
)
|
||||
return Effect.succeed({
|
||||
inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
|
||||
})
|
||||
}),
|
||||
)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid("Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||
)
|
||||
}
|
||||
|
||||
export const GoogleImages = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
|
||||
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
|
||||
import { SpeechStream } from "./utils/speech-stream.js"
|
||||
|
||||
const ADAPTER = "google-speech"
|
||||
const NAME = "Google Speech"
|
||||
const PROVIDER = ProviderID.make("google")
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
const DEFAULT_SAMPLE_RATE = 24000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Style is directed in the text itself, and `speechConfig.multiSpeakerVoiceConfig` excludes `voice`. */
|
||||
export type GoogleSpeechOptions = {
|
||||
readonly temperature?: number
|
||||
readonly seed?: number
|
||||
readonly speechConfig?: {
|
||||
readonly multiSpeakerVoiceConfig?: {
|
||||
readonly speakerVoiceConfigs: ReadonlyArray<{
|
||||
readonly speaker: string
|
||||
readonly voiceConfig: { readonly prebuiltVoiceConfig: { readonly voiceName: string } }
|
||||
}>
|
||||
}
|
||||
}
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<GoogleSpeechOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Streaming event schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GenerateContentChunk = GeminiGenerateContent.chunk(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
inlineData: Schema.optional(Schema.Struct({ mimeType: Schema.String, data: Schema.Uint8ArrayFromBase64 })),
|
||||
}),
|
||||
)
|
||||
|
||||
const decodeChunk = MediaProtocol.decodeFrame(ADAPTER, NAME, GenerateContentChunk)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
|
||||
readonly mimeType?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
if (request.format !== undefined && request.format !== "pcm")
|
||||
return yield* SpeechStream.unsupportedFormat(
|
||||
PROVIDER,
|
||||
ADAPTER,
|
||||
`${NAME} only returns raw PCM; request format "pcm" or omit it, then wrap the samples yourself`,
|
||||
)
|
||||
const voiceName = SpeechStream.voiceID(request.voice)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
contents: [{ role: "user", parts: [{ text: request.text }] }],
|
||||
generationConfig: mergeJsonRecords(
|
||||
{
|
||||
responseModalities: ["AUDIO"],
|
||||
speechConfig: {
|
||||
voiceConfig: voiceName === undefined ? undefined : { prebuiltVoiceConfig: { voiceName } },
|
||||
languageCode: request.language,
|
||||
},
|
||||
},
|
||||
request.providerOptions,
|
||||
),
|
||||
},
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: string) {
|
||||
const chunk = yield* decodeChunk(frame)
|
||||
const blocked = GeminiGenerateContent.blocked(NAME, chunk, frame)
|
||||
if (blocked !== undefined) return yield* blocked
|
||||
const audio = (chunk.candidates?.[0]?.content?.parts ?? []).flatMap((part) =>
|
||||
part.inlineData === undefined ? [] : [part.inlineData],
|
||||
)
|
||||
const next: State = { ...GeminiGenerateContent.track(state, chunk), mimeType: state.mimeType ?? audio[0]?.mimeType }
|
||||
return [next, audio.flatMap((part) => SpeechStream.delta(next, part.data)[1])] as const
|
||||
})
|
||||
|
||||
const finish = (state: State) => {
|
||||
const sampleRate = SpeechStream.sampleRate(state.mimeType) ?? DEFAULT_SAMPLE_RATE
|
||||
return SpeechStream.finish(ADAPTER, state, {
|
||||
...SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`),
|
||||
usage: GeminiGenerateContent.usage(state.usage),
|
||||
providerMetadata: GeminiGenerateContent.providerMetadata(state),
|
||||
detail: state.finishReason === undefined ? undefined : `finish reason: ${state.finishReason}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["instructions", "speed", "timestamps"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
|
||||
initial: () => ({ chunks: [] }),
|
||||
step,
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
SpeechModel.fromRoute<GoogleSpeechOptions, string, State>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
// Only `gemini-3.1-flash-tts-preview` and later stream; earlier TTS models reject `streamGenerateContent`.
|
||||
path: ({ request }) => GeminiGenerateContent.path(request.model.id, request.mode),
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const GoogleSpeech = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,214 +0,0 @@
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import {
|
||||
TranscriptionFinishEvent,
|
||||
TranscriptionModel,
|
||||
TranscriptionSegmentEvent,
|
||||
TranscriptionTextDeltaEvent,
|
||||
type TranscriptionRequestFor,
|
||||
type TranscriptionSegment,
|
||||
type TranscriptionWord,
|
||||
type TranscriptionEvent,
|
||||
} from "../transcription.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
|
||||
|
||||
const ADAPTER = "google-transcription"
|
||||
const NAME = "Google Transcription"
|
||||
const PROVIDER = ProviderID.make("google")
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merged into `generationConfig`. The API rejects `customVocabulary` and `mode: "SMART"` alongside diarization or word
|
||||
* timestamps.
|
||||
*/
|
||||
export type GoogleTranscriptionOptions = {
|
||||
readonly audioTranscriptionConfig?: {
|
||||
readonly mode?: "VERBATIM" | "SMART" | (string & {})
|
||||
readonly customVocabulary?: ReadonlyArray<string>
|
||||
readonly languageCodes?: ReadonlyArray<string>
|
||||
}
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = TranscriptionRequestFor<GoogleTranscriptionOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Streaming event schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Seconds = Schema.String.check(Schema.isPattern(/^\d+(\.\d+)?s$/)).pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((value) => Number.parseFloat(value)),
|
||||
encode: SchemaGetter.transform((value) => `${value}s`),
|
||||
}),
|
||||
)
|
||||
|
||||
const AudioTranscription = Schema.Struct({
|
||||
text: Schema.String,
|
||||
speakerLabel: Schema.optional(Schema.String),
|
||||
words: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
word: Schema.String,
|
||||
startOffset: Seconds,
|
||||
endOffset: Seconds,
|
||||
speakerLabel: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const decodeChunk = MediaProtocol.decodeFrame(
|
||||
ADAPTER,
|
||||
NAME,
|
||||
GeminiGenerateContent.chunk(Schema.Struct({ audioTranscription: Schema.optional(AudioTranscription) })),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface State extends GeminiGenerateContent.Metadata {
|
||||
readonly text: string
|
||||
readonly segments: Array<TranscriptionSegment>
|
||||
readonly words: Array<TranscriptionWord>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fromRequest = Effect.fn("GoogleTranscription.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
// General Gemini models ignore `audioTranscriptionConfig` and answer the audio conversationally.
|
||||
if (!request.model.id.includes("transcribe"))
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "transcription.model",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${request.model.id} is not a transcription model; use a transcribe model such as gemini-3.5-transcribe`,
|
||||
})
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
contents: [{ role: "user", parts: [yield* GeminiGenerateContent.mediaPart(ADAPTER, request.audio)] }],
|
||||
generationConfig: mergeJsonRecords(
|
||||
{
|
||||
audioTranscriptionConfig: {
|
||||
languageCodes: request.language === undefined ? undefined : [request.language],
|
||||
// Parts carry no offsets of their own, so segment times come from word offsets.
|
||||
wordTimestamp:
|
||||
request.diarize === true || request.timestamps === "word" || request.timestamps === "segment"
|
||||
? true
|
||||
: undefined,
|
||||
diarization: request.diarize === true ? true : undefined,
|
||||
},
|
||||
},
|
||||
request.providerOptions,
|
||||
),
|
||||
},
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const turn = (part: Schema.Schema.Type<typeof AudioTranscription>) => {
|
||||
const words = (part.words ?? []).map((word) => ({
|
||||
text: word.word,
|
||||
startSeconds: word.startOffset,
|
||||
endSeconds: word.endOffset,
|
||||
speaker: word.speakerLabel ?? part.speakerLabel,
|
||||
}))
|
||||
const first = words[0]
|
||||
const last = words.at(-1)
|
||||
return {
|
||||
text: part.text,
|
||||
words,
|
||||
segment:
|
||||
first === undefined || last === undefined
|
||||
? undefined
|
||||
: {
|
||||
text: part.text,
|
||||
startSeconds: first.startSeconds,
|
||||
endSeconds: last.endSeconds,
|
||||
speaker: part.speakerLabel,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const step = Effect.fn("GoogleTranscription.step")(function* (state: State, frame: string) {
|
||||
const chunk = yield* decodeChunk(frame)
|
||||
const blocked = GeminiGenerateContent.blocked(NAME, chunk, frame)
|
||||
if (blocked !== undefined) return yield* blocked
|
||||
const turns = (chunk.candidates?.[0]?.content?.parts ?? []).flatMap((part) =>
|
||||
part.audioTranscription === undefined ? [] : [turn(part.audioTranscription)],
|
||||
)
|
||||
const segments = turns.flatMap((item) => (item.segment === undefined ? [] : [item.segment]))
|
||||
state.words.push(...turns.flatMap((item) => item.words))
|
||||
state.segments.push(...segments)
|
||||
// Each part is one whole speaker turn without surrounding whitespace, so turns join with a space.
|
||||
const text = turns
|
||||
.map((item) => item.text)
|
||||
.filter((item) => item.length > 0)
|
||||
.join(" ")
|
||||
const delta = text.length === 0 || state.text.length === 0 ? text : ` ${text}`
|
||||
const events: ReadonlyArray<TranscriptionEvent> = [
|
||||
...(delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta })]),
|
||||
...segments.map((segment) => TranscriptionSegmentEvent.make({ segment })),
|
||||
]
|
||||
return [{ ...GeminiGenerateContent.track(state, chunk), text: state.text + delta }, events] as const
|
||||
})
|
||||
|
||||
const finish = (state: State) => {
|
||||
if (state.finishReason === undefined) return Effect.fail(MediaProtocol.incomplete(ADAPTER))
|
||||
return Effect.succeed([
|
||||
TranscriptionFinishEvent.make({
|
||||
text: state.text,
|
||||
segments: state.segments.length === 0 ? undefined : state.segments,
|
||||
words: state.words.length === 0 ? undefined : state.words,
|
||||
usage: GeminiGenerateContent.usage(state.usage),
|
||||
providerMetadata: GeminiGenerateContent.providerMetadata(state),
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, string, State>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["prompt", "speakers"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
|
||||
initial: () => ({ text: "", segments: [], words: [] }),
|
||||
step,
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
TranscriptionModel.fromRoute<GoogleTranscriptionOptions, string, State>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => GeminiGenerateContent.path(request.model.id, request.mode),
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const GoogleTranscription = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,233 +0,0 @@
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalArray } from "./shared.js"
|
||||
|
||||
const ADAPTER = "google-video"
|
||||
const NAME = "Google Veo"
|
||||
const PROVIDER = ProviderID.make("google")
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
/** Veo keeps generated files for two days; the asset carries that deadline so callers materialize in time. */
|
||||
const FILE_RETENTION = Duration.days(2)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GoogleVideoString<Known extends string> = Known | (string & {})
|
||||
|
||||
/** Provider-native `parameters`. Common fields (`aspectRatio`, `resolution`, `durationSeconds`, `seed`) live on the request. */
|
||||
export type GoogleVideoOptions = {
|
||||
readonly personGeneration?: GoogleVideoString<"allow_all" | "allow_adult" | "dont_allow">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<GoogleVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The long-running operation name, e.g. `models/veo-3.1-generate-preview/operations/abc123`. */
|
||||
export const Token = Schema.Struct({ operation: Schema.String })
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({ name: Schema.String })
|
||||
|
||||
const Operation = Schema.Struct({
|
||||
done: Schema.optional(Schema.Boolean),
|
||||
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
|
||||
response: Schema.optional(
|
||||
Schema.Struct({
|
||||
generateVideoResponse: Schema.optional(
|
||||
Schema.Struct({
|
||||
generatedSamples: optionalArray(
|
||||
Schema.Struct({
|
||||
video: Schema.optional(
|
||||
Schema.Struct({
|
||||
uri: Schema.optional(Schema.String),
|
||||
mimeType: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
raiMediaFilteredCount: Schema.optional(Schema.Number),
|
||||
raiMediaFilteredReasons: optionalArray(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
metadata: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Veo takes inline media only; a prior Veo output is `Media.url` with transient auth, so materialize it first.
|
||||
const inlineMedia = (asset: Media.Asset) =>
|
||||
ProviderShared.requireInlineMedia(NAME, asset).pipe(
|
||||
Effect.map((inline) => ({ inlineData: { mimeType: inline.mime, data: inline.base64 } })),
|
||||
)
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVideo.fromRequest")(function* (request: Request) {
|
||||
if (request.n !== undefined && request.n > 1)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "video.n",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${NAME} generates one video per request; call it once per video instead of n=${request.n}`,
|
||||
})
|
||||
if (request.audio === false)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "video.audio",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${NAME} always generates audio; audio: false cannot be honored`,
|
||||
})
|
||||
if (request.frames?.last !== undefined && request.frames.first === undefined)
|
||||
return yield* ProviderShared.invalidRequest(`${NAME} requires frames.first when frames.last is set`)
|
||||
const image = request.frames?.first === undefined ? undefined : yield* inlineMedia(request.frames.first)
|
||||
const lastFrame = request.frames?.last === undefined ? undefined : yield* inlineMedia(request.frames.last)
|
||||
const video = request.video === undefined ? undefined : yield* inlineMedia(request.video)
|
||||
const referenceImages = yield* Effect.forEach(request.references ?? [], (asset) =>
|
||||
inlineMedia(asset).pipe(Effect.map((image) => ({ image, referenceType: "asset" }))),
|
||||
)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
instances: [
|
||||
{
|
||||
prompt: request.prompt,
|
||||
image,
|
||||
lastFrame,
|
||||
referenceImages: referenceImages.length === 0 ? undefined : referenceImages,
|
||||
video,
|
||||
},
|
||||
],
|
||||
parameters: mergeJsonRecords(
|
||||
{
|
||||
aspectRatio: request.aspectRatio,
|
||||
resolution: request.resolution,
|
||||
durationSeconds: request.durationSeconds,
|
||||
negativePrompt: request.negativePrompt,
|
||||
seed: request.seed,
|
||||
},
|
||||
request.providerOptions,
|
||||
),
|
||||
},
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: { operation: value.name },
|
||||
snapshot: { id: value.name, status: "running" },
|
||||
}))
|
||||
|
||||
// Operations carry no status string: not done is running, done with `error` failed, otherwise completed.
|
||||
const statusOf = (operation: typeof Operation.Type): Status => {
|
||||
if (operation.done !== true) return "running"
|
||||
return operation.error === undefined ? "completed" : "failed"
|
||||
}
|
||||
|
||||
const decodeOperation = MediaProtocol.decodeJson(ADAPTER, NAME, Operation)
|
||||
|
||||
const decodeStatus = Effect.fn("GoogleVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeOperation(response)
|
||||
return { id: context.token.operation, status: statusOf(output.value) }
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeOperation(response)
|
||||
const operation = output.value
|
||||
const status = statusOf(operation)
|
||||
if (status === "running")
|
||||
return yield* output.invalid(`${NAME} operation ${context.token.operation} has not finished`)
|
||||
if (status === "failed")
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
`${NAME} operation failed${operation.error?.message === undefined ? "" : `: ${operation.error.message}`}`,
|
||||
)
|
||||
const generated = operation.response?.generateVideoResponse
|
||||
// Downloads require the same API key as the poll; the asset carries it transiently and follows the redirect.
|
||||
const videos = yield* Effect.forEach(
|
||||
(generated?.generatedSamples ?? []).flatMap((sample) =>
|
||||
sample.video?.uri === undefined ? [] : [{ uri: sample.video.uri, mimeType: sample.video.mimeType }],
|
||||
),
|
||||
(video) =>
|
||||
MediaProtocol.expiringUrl(video.uri, FILE_RETENTION, {
|
||||
mediaType: video.mimeType ?? "video/mp4",
|
||||
headers: context.auth,
|
||||
}),
|
||||
)
|
||||
const reasons = generated?.raiMediaFilteredReasons ?? []
|
||||
const notices = reasons.map((reason) => ({
|
||||
type: "filtered" as const,
|
||||
message: `${NAME} filtered media: ${reason}`,
|
||||
providerMetadata: { google: { raiMediaFilteredReason: reason } },
|
||||
}))
|
||||
if (videos.length === 0 && (reasons.length > 0 || (generated?.raiMediaFilteredCount ?? 0) > 0))
|
||||
return yield* output.contentPolicy(
|
||||
`${NAME} filtered every video${reasons.length === 0 ? "" : `: ${reasons.join("; ")}`}`,
|
||||
)
|
||||
if (videos.length === 0) return yield* output.invalid(`${NAME} operation completed without any video`)
|
||||
return new VideoResponse({
|
||||
videos,
|
||||
notices: notices.length === 0 ? undefined : notices,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
operation: context.token.operation,
|
||||
raiMediaFilteredCount: generated?.raiMediaFilteredCount,
|
||||
metadata: operation.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const operationPath = (token: Token) => `/${token.operation}`
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: operationPath, decode: decodeStatus },
|
||||
result: { path: operationPath, decode: decodeResult },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<GoogleVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => `/models/${request.model.id}:predictLongRunning`,
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const GoogleVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { JsonObject, ProviderShared, optionalNull } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
const ADAPTER = "meta-images"
|
||||
const NAME = "Meta Images"
|
||||
const PROVIDER = ProviderID.make("meta")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OpenString<Known extends string> = Known | (string & {})
|
||||
|
||||
/** Provider-native options. Common fields (`n`, `size`, `format`, `images`) live on the request. */
|
||||
export type ImageOptions = {
|
||||
readonly responseFormat?: OpenString<"b64_json" | "url">
|
||||
readonly reasoningStrength?: OpenString<"low" | "high">
|
||||
readonly toolEnablement?: {
|
||||
readonly enable_image_search?: boolean
|
||||
readonly enable_web_search?: boolean
|
||||
readonly enable_shell?: boolean
|
||||
}
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
export type Request = ImageRequestFor<ImageOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Request body and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Body = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
model: Schema.String,
|
||||
prompt: Schema.String,
|
||||
images: Schema.optional(Schema.Array(JsonObject)),
|
||||
n: Schema.optional(Schema.Number),
|
||||
/** Aspect ratio hint, not an exact output resolution. */
|
||||
size: Schema.optional(Schema.String),
|
||||
output_format: Schema.optional(Schema.String),
|
||||
response_format: Schema.optional(Schema.String),
|
||||
reasoning_strength: Schema.optional(Schema.String),
|
||||
tool_enablement: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
|
||||
const Response = Schema.Struct({
|
||||
data: Schema.Array(Schema.Struct({ b64_json: optionalNull(Schema.String), url: optionalNull(Schema.String) })),
|
||||
output_format: Schema.optional(Schema.String),
|
||||
usage: Schema.optional(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
|
||||
|
||||
// Meta has no file handles: refs are rejected even when they name this provider.
|
||||
const reference = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((item) => ({ image_url: item.value })))
|
||||
|
||||
const fromRequest = Effect.fn("MetaImages.fromRequest")(function* (request: Request) {
|
||||
const images = yield* Effect.forEach(request.images ?? [], reference)
|
||||
const { responseFormat, reasoningStrength, toolEnablement, ...native } = request.providerOptions ?? {}
|
||||
const payload = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
images: images.length === 0 ? undefined : images,
|
||||
n: request.n,
|
||||
size: request.size,
|
||||
output_format: request.format,
|
||||
response_format: responseFormat,
|
||||
reasoning_strength: reasoningStrength,
|
||||
tool_enablement: toolEnablement,
|
||||
},
|
||||
native,
|
||||
request.http?.body,
|
||||
),
|
||||
)
|
||||
return MediaProtocol.json(payload)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeResponse = Effect.fn("MetaImages.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.DecodeContext<Request>,
|
||||
) {
|
||||
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, Response)(response)
|
||||
const decoded = output.value
|
||||
const requested = context.body.type === "json" ? context.body.value.output_format : undefined
|
||||
const format = decoded.output_format ?? (typeof requested === "string" ? requested : "webp")
|
||||
const mediaType = `image/${format}`
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
if (item.b64_json)
|
||||
return MediaInput.decodedAsset(output.invalid, `${NAME} result ${index}`, item.b64_json, mediaType, {
|
||||
info: { format },
|
||||
})
|
||||
if (item.url) return Effect.succeed(Media.url(item.url, { mediaType, info: { format } }))
|
||||
return Effect.fail(output.invalid(`${NAME} result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* output.invalid(`${NAME} returned no images`)
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
decoded.usage === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: "tokens",
|
||||
input: decoded.usage.input_tokens,
|
||||
output: decoded.usage.output_tokens,
|
||||
total: decoded.usage.total_tokens,
|
||||
details: { meta: decoded.usage },
|
||||
},
|
||||
providerMetadata: { meta: { outputFormat: format } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["mask", "aspectRatio", "seed"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput & { readonly baseURL: string }) =>
|
||||
ImageModel.fromRoute<ImageOptions>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
path: ({ request }) => `/images/${isEdit(request) ? "edits" : "generations"}`,
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export * as MetaImages from "./meta-images.js"
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import type { LLMRequest } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "./anthropic-messages.js"
|
||||
import { MetaResponses } from "./meta-responses.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
|
||||
const WebSearch = Schema.Struct({
|
||||
type: Schema.Literal("web_search"),
|
||||
name: Schema.Literal("web_search"),
|
||||
user_location: MetaResponses.WebSearch.fields.user_location,
|
||||
})
|
||||
const Body = Schema.Struct({
|
||||
...AnthropicMessages.AnthropicMessagesBody.fields,
|
||||
tools: optionalArray(
|
||||
Schema.Union([
|
||||
Schema.Struct({ name: Schema.String, description: Schema.String, input_schema: JsonObject }),
|
||||
WebSearch,
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("MetaMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const projected = ProviderShared.flattenToolRequest(request)
|
||||
const body = yield* AnthropicMessages.protocol.body.from(projected.request)
|
||||
return {
|
||||
...body,
|
||||
tools:
|
||||
body.tools === undefined
|
||||
? undefined
|
||||
: yield* Effect.forEach(body.tools, (tool, index) =>
|
||||
Effect.gen(function* () {
|
||||
const native = projected.tools[index]?.native
|
||||
if (native === undefined) return tool
|
||||
const search = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MetaResponses.WebSearch))(
|
||||
native.meta,
|
||||
)
|
||||
if (search.search_context_size !== undefined)
|
||||
return yield* ProviderShared.invalidRequest("Meta Messages does not support searchContextSize")
|
||||
return { type: "web_search" as const, name: "web_search" as const, user_location: search.user_location }
|
||||
}),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "meta-messages",
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
})
|
||||
|
||||
export * as MetaMessages from "./meta-messages.js"
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { MetaImage } from "./utils/meta-image.js"
|
||||
|
||||
const ADAPTER = "meta-responses"
|
||||
const NAME = "Meta Responses"
|
||||
|
||||
export const WebSearch = Schema.Struct({
|
||||
type: Schema.Literal("web_search"),
|
||||
search_context_size: Schema.optional(Schema.String),
|
||||
user_location: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("approximate"),
|
||||
city: Schema.optional(Schema.String),
|
||||
region: Schema.optional(Schema.String),
|
||||
country: Schema.optional(Schema.String),
|
||||
timezone: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const ImageGeneration = Schema.Struct({
|
||||
type: Schema.Literal("image_generation"),
|
||||
size: Schema.optional(Schema.String),
|
||||
output_format: Schema.optional(Schema.String),
|
||||
reasoning_strength: Schema.optional(Schema.String),
|
||||
enable_image_search: Schema.optional(Schema.Boolean),
|
||||
enable_web_search: Schema.optional(Schema.Boolean),
|
||||
enable_shell: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const NativeTool = Schema.Union([WebSearch, ImageGeneration])
|
||||
const ImageItem = Schema.Struct({
|
||||
type: Schema.Literal("image_generation_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
result: optionalNull(Schema.String),
|
||||
output_format: Schema.optional(Schema.String),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
const Body = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, ImageItem])),
|
||||
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const MessageAnnotations = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ annotations: optionalArray(JsonObject) })),
|
||||
})
|
||||
|
||||
interface ParserState extends OpenResponses.ParserState {
|
||||
readonly completedItems: ReadonlySet<string>
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(ImageItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
const projected = ProviderShared.flattenToolRequest(
|
||||
LLMRequest.update(request, {
|
||||
messages: request.messages.map((message) =>
|
||||
Message.make({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (
|
||||
part.type !== "tool-result" ||
|
||||
!part.providerExecuted ||
|
||||
part.name !== "image_generation" ||
|
||||
part.result.type !== "content" ||
|
||||
part.providerMetadata?.[key]?.itemId !== part.id
|
||||
)
|
||||
return part
|
||||
// Meta's signed image ID carries edit state; replay the handle, not the image bytes as a user message.
|
||||
return ToolResultPart.make({
|
||||
...part,
|
||||
result: {
|
||||
type: "json",
|
||||
value: { type: "image_generation_call", id: part.id, status: "completed", result: null },
|
||||
},
|
||||
})
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
|
||||
...(yield* OpenResponses.lowerConversation(projected.request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
tools:
|
||||
projected.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(projected.tools, (tool) =>
|
||||
Effect.gen(function* () {
|
||||
if (tool.native === undefined)
|
||||
return yield* OpenResponses.lowerTool(
|
||||
NAME,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model.compatibility?.toolSchema),
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
|
||||
}),
|
||||
),
|
||||
tool_choice:
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* OpenResponses.lowerToolChoice(NAME, request.toolChoice) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
image_generation_call: {
|
||||
name: "image_generation",
|
||||
input: () => ({}),
|
||||
result: Effect.fn("MetaResponses.imageResult")(function* (raw: ResponsesHostedTools.Item) {
|
||||
const item = yield* Schema.decodeUnknownEffect(ImageItem)(raw).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Meta returned an invalid image item",
|
||||
ProviderShared.encodeJson(raw),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (item.error !== undefined && item.error !== null) return { type: "error" as const, value: item.error }
|
||||
if (!item.result)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Meta returned an image without data",
|
||||
ProviderShared.encodeJson(raw),
|
||||
)
|
||||
const data = yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Meta returned invalid image base64",
|
||||
ProviderShared.encodeJson(raw),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
const mime = MetaImage.mediaType(data, item.output_format)
|
||||
return {
|
||||
type: "content" as const,
|
||||
value: [{ type: "file" as const, uri: `data:${mime};base64,${item.result}`, mime }],
|
||||
}
|
||||
}),
|
||||
},
|
||||
} satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const onEvent = Effect.fn("MetaResponses.onEvent")(function* (
|
||||
state: OpenResponses.ParserState,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
const event = OpenResponses.normalize(state, input)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return yield* ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
const result = yield* OpenResponses.step(state, event)
|
||||
if (event.type !== "response.output_item.done" || event.item?.type !== "message") return result
|
||||
const message = yield* Schema.decodeUnknownEffect(MessageAnnotations)(event.item).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Meta returned invalid message annotations",
|
||||
ProviderShared.encodeJson(event),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
const annotations = message.content.flatMap((part) => part.annotations ?? [])
|
||||
if (annotations.length === 0) return result
|
||||
return [
|
||||
result[0],
|
||||
result[1].map((item) =>
|
||||
LLMEvent.is.textEnd(item)
|
||||
? LLMEvent.textEnd({
|
||||
...item,
|
||||
providerMetadata: {
|
||||
...item.providerMetadata,
|
||||
[state.providerMetadataKey]: { ...item.providerMetadata?.[state.providerMetadataKey], annotations },
|
||||
},
|
||||
})
|
||||
: item,
|
||||
),
|
||||
] satisfies OpenResponses.StepResult
|
||||
})
|
||||
|
||||
const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, input: OpenResponses.Event) {
|
||||
const completedItems = new Set(state.completedItems)
|
||||
const event = OpenResponses.normalize(state, input)
|
||||
if (event.type === "response.output_item.done" && event.item && completedItems.has(event.item.id))
|
||||
return [state, []] as const
|
||||
const events: LLMEvent[] = []
|
||||
let current: OpenResponses.ParserState = state
|
||||
// Muse Image delivers its image and optional summary only in response.completed.
|
||||
// Recover terminal-only items in order, without duplicating Spark's streamed items.
|
||||
if (event.type === "response.completed") {
|
||||
for (const [index, item] of (event.response?.output ?? []).entries()) {
|
||||
const done = OpenResponses.normalize(current, { type: "response.output_item.done", item, output_index: index })
|
||||
// Spark changes reasoning IDs in the terminal snapshot; output indices still identify the streamed items.
|
||||
if (!done.item || completedItems.has(done.item.id) || completedItems.has(state.outputItems[index] ?? "")) continue
|
||||
const result = yield* onEvent(current, done)
|
||||
current = result[0]
|
||||
events.push(...result[1])
|
||||
completedItems.add(done.item.id)
|
||||
}
|
||||
}
|
||||
const result = yield* onEvent(current, event)
|
||||
if (event.type === "response.output_item.done" && event.item) completedItems.add(event.item.id)
|
||||
return [{ ...result[0], completedItems }, [...events, ...result[1]]] as const
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request): ParserState => ({ ...OpenResponses.initial(request, adapter), completedItems: new Set() }),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
})
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<Schema.Schema.Type<typeof Body>>()
|
||||
|
||||
export * as MetaResponses from "./meta-responses.js"
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
LLMEvent,
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReasonDetails,
|
||||
type LLMRequest,
|
||||
@@ -21,11 +19,11 @@ import {
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "mistral-chat"
|
||||
const DONE = "[DONE]" as const
|
||||
const TOOL_ID = /^[A-Za-z0-9]{9}$/
|
||||
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
@@ -223,14 +221,40 @@ const MistralEvent = Schema.StructWithRest(
|
||||
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
|
||||
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
|
||||
|
||||
const hashID = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
const toolIDNormalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (TOOL_ID.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hashID(id)
|
||||
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
const url =
|
||||
ProviderShared.mediaUrl(part.media) ??
|
||||
(yield* ProviderShared.requireInlineMedia("Mistral Chat", part.media)).dataUrl
|
||||
if (mime.startsWith("image/")) return { type: "image_url" as const, image_url: url }
|
||||
if (mime === "application/pdf") return { type: "document_url" as const, document_url: url }
|
||||
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.media.mediaType}`)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
|
||||
if (media.mime.startsWith("image/")) return { type: "image_url" as const, image_url: url }
|
||||
if (media.mime === "application/pdf") return { type: "document_url" as const, document_url: url }
|
||||
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
|
||||
@@ -318,7 +342,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
content.push({ type: "text", text: item.text })
|
||||
continue
|
||||
}
|
||||
content.push(yield* lowerMedia(ProviderShared.toolFileMedia(item)))
|
||||
content.push(yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }))
|
||||
}
|
||||
output.push({
|
||||
role: "tool",
|
||||
@@ -333,7 +357,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const normalizeID = MistralToolID.normalizer(request)
|
||||
const normalizeID = toolIDNormalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
for (const message of request.messages) {
|
||||
@@ -556,13 +580,13 @@ const appendContent = (
|
||||
}
|
||||
|
||||
const normalizeStreamToolID = (state: ParserState, source: string) => {
|
||||
if (MistralToolID.valid.test(source))
|
||||
if (TOOL_ID.test(source))
|
||||
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
|
||||
const previous = state.toolIDs.get(source)
|
||||
if (previous) return { id: previous, state }
|
||||
let attempt = 0
|
||||
let id = MistralToolID.hash(source)
|
||||
while (state.usedToolIDs.has(id)) id = MistralToolID.hash(`${source}:${++attempt}`)
|
||||
let id = hashID(source)
|
||||
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
|
||||
return {
|
||||
id,
|
||||
state: {
|
||||
@@ -676,16 +700,6 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
|
||||
normalized: mapFinishReason(choice.finish_reason),
|
||||
raw: choice.finish_reason,
|
||||
}
|
||||
if (finishReason.normalized === "error") {
|
||||
const details = {
|
||||
message: `Mistral Chat stopped with ${finishReason.raw}`,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}
|
||||
return yield* new AIError({
|
||||
reason:
|
||||
finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details),
|
||||
})
|
||||
}
|
||||
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
|
||||
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(
|
||||
|
||||
@@ -18,6 +18,7 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
|
||||
])
|
||||
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
|
||||
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
export interface Options {
|
||||
readonly id: string
|
||||
@@ -26,7 +27,6 @@ export interface Options {
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly continuation?: OpenResponsesContinuation.Shape
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
}),
|
||||
observe: (_create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -73,6 +73,11 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
)
|
||||
if (event.type === "error") {
|
||||
terminal = true
|
||||
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
|
||||
),
|
||||
)
|
||||
return {
|
||||
type: "provider-failure",
|
||||
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
|
||||
@@ -158,15 +163,10 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
continuation: options.continuation,
|
||||
}),
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
if (input.webSocket && !channel)
|
||||
yield* Effect.logWarning(`${options.name} does not offer WebSocket for this endpoint; using HTTP`, {
|
||||
url: parts.url,
|
||||
})
|
||||
return {
|
||||
http: {
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "open-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
interface CheckpointValue {
|
||||
readonly version: typeof VERSION
|
||||
@@ -14,19 +15,12 @@ interface CheckpointValue {
|
||||
readonly output: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
|
||||
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
|
||||
*/
|
||||
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
|
||||
|
||||
export interface DriverInput {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
readonly continuation?: Shape
|
||||
}
|
||||
|
||||
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
|
||||
@@ -108,7 +102,7 @@ const incremental = (
|
||||
return input.slice(baseline.length)
|
||||
}
|
||||
|
||||
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
|
||||
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
|
||||
const rejected = (
|
||||
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
|
||||
@@ -133,26 +127,22 @@ const rejected = (
|
||||
|
||||
export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const { previous_response_id: _previousResponseID, ...request } = input.request
|
||||
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
|
||||
let output: OpenResponses.StreamItem[] = []
|
||||
return {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
output = []
|
||||
const previous = checkpointValue(checkpoint)
|
||||
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
|
||||
const fields = previous ? shape(request) : undefined
|
||||
const delta = previous && fields ? incremental(request, previous) : undefined
|
||||
if (!previous || !fields || !delta)
|
||||
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
const delta = previous ? incremental(request, previous) : undefined
|
||||
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
return {
|
||||
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
|
||||
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
|
||||
mode: "incremental" as const,
|
||||
}
|
||||
}),
|
||||
observe: (create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -163,15 +153,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const rejection = code(event)
|
||||
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
|
||||
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
|
||||
// no code. Classified failures such as context overflow keep their runner-owned recovery.
|
||||
if (
|
||||
create.mode === "incremental" &&
|
||||
observation.error.reason._tag === "InvalidRequest" &&
|
||||
observation.error.reason.classification === undefined
|
||||
)
|
||||
return rejected(observation, "retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
@@ -191,7 +172,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
responseID,
|
||||
request,
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output?.length
|
||||
output: event.response?.output
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
@@ -205,4 +186,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
@@ -18,10 +18,8 @@ import {
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import type { Media } from "../media.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate } from "../effort-updates.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -68,6 +66,7 @@ type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
export const MessageMetadata = Schema.Struct({
|
||||
itemId: Schema.optional(Schema.String),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
status: Schema.optional(Schema.String),
|
||||
phase: Schema.optional(MessagePhase),
|
||||
})
|
||||
@@ -165,21 +164,14 @@ export const CompactionItem = Schema.Struct({
|
||||
encrypted_content: Schema.String,
|
||||
})
|
||||
|
||||
// Kept out of the baseline `InputItem` union: only the OpenAI extension accepts it.
|
||||
export const ConfigurationUpdate = Schema.Struct({
|
||||
type: Schema.Literal("configuration_update"),
|
||||
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
|
||||
})
|
||||
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
CompactionItem,
|
||||
Schema.Struct({ type: Schema.tag("message"), role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ type: Schema.tag("message"), role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
role: Schema.tag("user"),
|
||||
content: Schema.Array(OpenResponsesInputContent),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
@@ -216,7 +208,6 @@ export type HostedToolReplayItem = {
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| HostedToolReplayItem
|
||||
| ConfigurationUpdate
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
@@ -334,13 +325,48 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// Responses-compatible providers put error details at the top level, under `error`, or under
|
||||
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
|
||||
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
|
||||
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
|
||||
// The Responses schema puts streaming error details at the top level and
|
||||
// response failures under `response.error`. WebSocket failures use an
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// https://www.openresponses.org/specification
|
||||
const asText = (value: unknown) =>
|
||||
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("error"),
|
||||
status: Schema.optional(Schema.Number),
|
||||
status_code: Schema.optional(Schema.Number),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
|
||||
|
||||
export const decodeKnownErrorEvent = (event: Event) =>
|
||||
decodeWebSocketErrorEvent({
|
||||
...event,
|
||||
status: typeof event.status === "number" ? event.status : undefined,
|
||||
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
|
||||
headers: ProviderShared.isRecord(event.headers)
|
||||
? Object.fromEntries(
|
||||
Object.entries(event.headers).filter(
|
||||
(entry): entry is [string, string | number | boolean] =>
|
||||
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -361,50 +387,30 @@ export const Event = Schema.StructWithRest(
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
output: Schema.optional(Schema.Array(StreamItem)),
|
||||
usage: optionalNull(OpenResponsesUsage),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
code: Schema.optional(Schema.Unknown),
|
||||
message: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
status: Schema.optional(Schema.Unknown),
|
||||
status_code: Schema.optional(Schema.Unknown),
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
|
||||
const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
|
||||
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
|
||||
* failing decoding.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly nativeTool?: (
|
||||
native: NonNullable<ToolDefinition["native"]>,
|
||||
) => Effect.Effect<{ readonly type: string }, AIError>
|
||||
readonly lowerMedia?: (input: {
|
||||
readonly part: MediaPart
|
||||
readonly media: Media.Inline | undefined
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
|
||||
@@ -485,7 +491,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
namespace: part.namespace,
|
||||
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,28 +517,29 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
adapter: ProviderAdapter,
|
||||
target: "message" | "tool-result",
|
||||
) {
|
||||
const media = part.media.inline()
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const providerMedia = adapter.lowerMedia?.({ part, media, request })
|
||||
if (providerMedia) return providerMedia
|
||||
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
|
||||
part.providerMetadata?.[metadataKey(request.model)]?.detail,
|
||||
)
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
const url = ProviderShared.mediaUrl(part.media)
|
||||
const location = url ?? (yield* ProviderShared.requireInlineMedia(adapter.name, part.media)).dataUrl
|
||||
if (part.media.kind !== "image") {
|
||||
if (target === "tool-result" && part.media.kind === "video")
|
||||
return { type: "input_video" as const, video_url: location }
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
: undefined
|
||||
if (!media.mime.startsWith("image/")) {
|
||||
if (target === "tool-result" && media.mime.startsWith("video/"))
|
||||
return { type: "input_video" as const, video_url: url ?? media.dataUrl }
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
detail,
|
||||
...(url ? { file_url: url } : { file_data: location }),
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "input_image" as const,
|
||||
image_url: location,
|
||||
image_url: url ?? media.dataUrl,
|
||||
detail,
|
||||
}
|
||||
})
|
||||
@@ -562,7 +569,12 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMedia(ProviderShared.toolFileMedia(item), request, adapter, "tool-result")
|
||||
return yield* lowerMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
adapter,
|
||||
"tool-result",
|
||||
)
|
||||
})
|
||||
|
||||
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||
@@ -571,7 +583,11 @@ const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMessageMedia(ProviderShared.toolFileMedia(item), request, adapter)
|
||||
return yield* lowerMessageMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
adapter,
|
||||
)
|
||||
})
|
||||
|
||||
const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
@@ -587,8 +603,6 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
|
||||
})
|
||||
|
||||
const DEFAULT_EFFORT = "medium"
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
@@ -601,16 +615,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
|
||||
)(message.providerMetadata?.[providerMetadataKey])
|
||||
if (message.role === "system") {
|
||||
const update = effortUpdate(message)
|
||||
if (update) {
|
||||
// Consecutive updates are rejected, so a newer one replaces its predecessor.
|
||||
const last = input.at(-1)
|
||||
if (last !== undefined && "type" in last && last.type === "configuration_update") input.pop()
|
||||
input.push({ type: "configuration_update", reasoning: { effort: update.effort ?? DEFAULT_EFFORT } })
|
||||
continue
|
||||
}
|
||||
input.push({
|
||||
type: "message",
|
||||
role: "developer",
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
|
||||
})
|
||||
@@ -620,7 +625,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
if (message.role === "user") {
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
|
||||
if (content.length > 0)
|
||||
input.push({ type: "message", role: "user", content, id: metadata?.itemId, status: metadata?.status })
|
||||
input.push({ role: "user", content, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -647,8 +652,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
type: "message" as const,
|
||||
...(group.id === undefined ? {} : { id: group.id }),
|
||||
role: "assistant" as const,
|
||||
// Replayed text is a finished input item, even if generation was cut short.
|
||||
status: "completed",
|
||||
status: metadata?.status,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
@@ -711,28 +715,16 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
? part.result.value
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
input.push({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
flushText()
|
||||
// Responses has no assistant-authored image item; replay generated media (e.g. from Gemini) as user input.
|
||||
input.push({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [yield* lowerMessageMedia(part, request, adapter)],
|
||||
})
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
|
||||
"text",
|
||||
"reasoning",
|
||||
"tool-call",
|
||||
"tool-result",
|
||||
"media",
|
||||
])
|
||||
}
|
||||
flushText()
|
||||
@@ -765,7 +757,8 @@ export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(fu
|
||||
}
|
||||
})
|
||||
|
||||
export const lowerGeneration = (request: LLMRequest, options = OpenResponsesOptions.resolve(request)) => {
|
||||
export const lowerGeneration = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const generation = request.generation
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
@@ -826,13 +819,11 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
projected.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(projected.tools, (tool) =>
|
||||
tool.native !== undefined && adapter.nativeTool
|
||||
? adapter.nativeTool(tool.native)
|
||||
: lowerTool(
|
||||
adapter.name,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
lowerTool(
|
||||
adapter.name,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
),
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
@@ -1370,21 +1361,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
|
||||
export const errorDetail = (event: Event) => {
|
||||
const raw = event.error ?? event.response?.error
|
||||
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
|
||||
return {
|
||||
message: asText(event.message) ?? asText(nested?.message),
|
||||
code: asText(event.code) ?? asText(nested?.code),
|
||||
}
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
// When both code and message are present, prefix the code so consumers see
|
||||
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
||||
// the bare message — production rate limits and context-length failures used
|
||||
// to be indistinguishable from generic stream drops. Returns undefined when
|
||||
// the payload carries no usable summary.
|
||||
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
|
||||
const message = event.message || nested?.message || undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
if (message && code) return `${code}: ${message}`
|
||||
return message || code
|
||||
}
|
||||
|
||||
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
|
||||
// visible; fall back to the raw frame rather than a generic message when neither decodes.
|
||||
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
|
||||
const detail = errorDetail(event)
|
||||
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
@@ -1467,7 +1459,18 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
state.id,
|
||||
`${state.name} returned a malformed error event`,
|
||||
ProviderShared.encodeJson(event),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
|
||||
)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
@@ -76,44 +76,6 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
// `reasoning_details` carries two dialects. OpenRouter's `reasoning.*` entries
|
||||
// must be replayed unmodified (`index` included), so they keep every field they
|
||||
// arrived with. Kimi's OpenAI-compatible surface streams preserved thinking as
|
||||
// bare `summary` / `encrypted` entries keyed by a stream-only `index`; Kimi does
|
||||
// not document this publicly, so the handling follows Kimi Code (Kimi's own
|
||||
// client): merge summary deltas by `index`, replay without `index`, and always
|
||||
// send `reasoning_content` alongside. Anything else is dropped at the boundary.
|
||||
const OpenRouterDetailFields = {
|
||||
id: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
format: Schema.optional(Schema.String),
|
||||
index: Schema.optional(Schema.Number),
|
||||
signature: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
}
|
||||
const ReasoningDetail = Schema.Union([
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ type: Schema.Literal("reasoning.text"), text: Schema.optional(Schema.String), ...OpenRouterDetailFields }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning.summary"),
|
||||
summary: Schema.optional(Schema.String),
|
||||
...OpenRouterDetailFields,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ type: Schema.Literal("reasoning.encrypted"), data: Schema.String, ...OpenRouterDetailFields }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.Struct({ type: Schema.Literal("summary"), summary: Schema.String, index: Schema.optional(Schema.Number) }),
|
||||
Schema.Struct({ type: Schema.Literal("encrypted"), encrypted: Schema.String, index: Schema.optional(Schema.Number) }),
|
||||
])
|
||||
type ReasoningDetail = Schema.Schema.Type<typeof ReasoningDetail>
|
||||
const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
|
||||
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
|
||||
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
|
||||
|
||||
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
|
||||
// extension until direct Google OpenAI-compatible routing is supported here:
|
||||
// https://github.com/vercel/ai/issues/11590
|
||||
@@ -303,9 +265,7 @@ export interface ParserState {
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: string
|
||||
/** A scalar reasoning field (`reasoning_content`, ...) has carried text in this stream. */
|
||||
readonly reasoningTextObserved: boolean
|
||||
readonly reasoningDetails: Array<ReasoningDetail>
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
readonly reasoningDetailsObserved: boolean
|
||||
readonly reasoningEmitted: boolean
|
||||
readonly latestToolIndex?: number
|
||||
@@ -355,16 +315,15 @@ const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChat
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
|
||||
if (part.media.kind !== "image")
|
||||
return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.media.mediaType}`)
|
||||
const url =
|
||||
ProviderShared.mediaUrl(part.media) ?? (yield* ProviderShared.requireInlineMedia("OpenAI Chat", part.media)).dataUrl
|
||||
return { type: "image_url" as const, image_url: { url } }
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
if (!media.mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.mediaType}`)
|
||||
return { type: "image_url" as const, image_url: { url: media.dataUrl } }
|
||||
})
|
||||
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
@@ -381,21 +340,10 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown,
|
||||
return Array.isArray(details) ? details : []
|
||||
})
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
|
||||
return knownReasoningDetails(observed).map(lowerReasoningDetail)
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details))
|
||||
return knownReasoningDetails(native.reasoning_details).map(lowerReasoningDetail)
|
||||
return observed
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
// Kimi rejects its stream-only `index` on requests
|
||||
// ("the reasoning_details ... must not contain streaming index").
|
||||
const lowerReasoningDetail = (detail: ReasoningDetail) => {
|
||||
if (detail.type === "summary") return { type: detail.type, summary: detail.summary }
|
||||
if (detail.type === "encrypted") return { type: detail.type, encrypted: detail.encrypted }
|
||||
return detail
|
||||
}
|
||||
|
||||
const isKimiDetail = (detail: { readonly type: string }) => detail.type === "summary" || detail.type === "encrypted"
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
@@ -461,9 +409,6 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
if (observedField !== undefined) return observedField
|
||||
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||
if (!fullyStructured || requireReasoning) return "reasoning_content"
|
||||
// Kimi always expects `reasoning_content` on replayed assistant messages,
|
||||
// even when thinking arrived only through structured details.
|
||||
if (details?.some(isKimiDetail)) return "reasoning_content"
|
||||
})()
|
||||
const reasoningText = (() => {
|
||||
if (configuredField !== undefined)
|
||||
@@ -511,7 +456,11 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
images.push(...(yield* Effect.forEach(files, (item) => lowerMedia(ProviderShared.toolFileMedia(item)))))
|
||||
images.push(
|
||||
...(yield* Effect.forEach(files, (item) =>
|
||||
lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }),
|
||||
)),
|
||||
)
|
||||
}
|
||||
return { messages, images }
|
||||
})
|
||||
@@ -762,11 +711,7 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode
|
||||
|
||||
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
// Default off: strict providers 400 on unknown body fields, so only send
|
||||
// the key where compatibility explicitly allows it. Header-based affinity
|
||||
// (x-session-affinity, x-grok-conv-id, ...) is unaffected.
|
||||
const cacheKey =
|
||||
(request.model.compatibility?.supportsPromptCacheKey ?? false) ? ProviderShared.promptCacheKey(request) : undefined
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
return {
|
||||
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
|
||||
// For providers that support `store`, ensure stateless `store:false` is sent
|
||||
@@ -821,7 +766,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
supportsStrictMode,
|
||||
),
|
||||
),
|
||||
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
|
||||
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
|
||||
@@ -935,74 +880,44 @@ const reasoningDelta = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
const detailText = (details: ReadonlyArray<ReasoningDetail>, hideKimiSummary: boolean) => {
|
||||
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||
const text = details.flatMap((detail) => {
|
||||
if (detail.type === "reasoning.text") return detail.text ? [detail.text] : []
|
||||
if (detail.type === "reasoning.summary") return detail.summary ? [detail.summary] : []
|
||||
// Kimi streams the full thinking through `reasoning_content` and a separate
|
||||
// summary through details; show the summary only when nothing else does.
|
||||
if (detail.type === "summary") return detail.summary && !hideKimiSummary ? [detail.summary] : []
|
||||
if (!isRecord(detail)) return []
|
||||
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
|
||||
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
|
||||
return [detail.summary]
|
||||
return []
|
||||
})
|
||||
if (text.length > 0) return text.join("")
|
||||
}
|
||||
|
||||
const appendReasoningDetails = (result: Array<ReasoningDetail>, details: ReadonlyArray<ReasoningDetail>) => {
|
||||
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
|
||||
for (const detail of details) {
|
||||
const previous = result.at(-1)
|
||||
const merged = previous === undefined ? undefined : mergeReasoningDetails(previous, detail)
|
||||
if (merged === undefined) {
|
||||
if (
|
||||
!isRecord(previous) ||
|
||||
previous.type !== "reasoning.text" ||
|
||||
!isRecord(detail) ||
|
||||
detail.type !== "reasoning.text" ||
|
||||
conflictingReasoningTextDetails(previous, detail)
|
||||
) {
|
||||
result.push(detail)
|
||||
continue
|
||||
}
|
||||
result[result.length - 1] = merged
|
||||
result[result.length - 1] = {
|
||||
...previous,
|
||||
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
|
||||
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
|
||||
signature: mergeDetailValue(previous.signature, detail.signature),
|
||||
format: mergeDetailValue(previous.format, detail.format),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consecutive text or summary deltas of the same kind accumulate into one
|
||||
// entry; encrypted entries are opaque and never merge.
|
||||
const mergeReasoningDetails = (previous: ReasoningDetail, detail: ReasoningDetail): ReasoningDetail | undefined => {
|
||||
if (conflictingReasoningDetails(previous, detail)) return undefined
|
||||
if (previous.type === "reasoning.text" && detail.type === "reasoning.text")
|
||||
return {
|
||||
...previous,
|
||||
...detail,
|
||||
text: `${previous.text ?? ""}${detail.text ?? ""}`,
|
||||
...mergeDetailIdentity(previous, detail),
|
||||
}
|
||||
if (previous.type === "reasoning.summary" && detail.type === "reasoning.summary")
|
||||
return {
|
||||
...previous,
|
||||
...detail,
|
||||
summary: `${previous.summary ?? ""}${detail.summary ?? ""}`,
|
||||
...mergeDetailIdentity(previous, detail),
|
||||
}
|
||||
if (previous.type === "summary" && detail.type === "summary")
|
||||
return { ...previous, ...detail, summary: previous.summary + detail.summary }
|
||||
}
|
||||
|
||||
type DetailIdentity = {
|
||||
readonly id?: string | null
|
||||
readonly index?: number
|
||||
readonly format?: string
|
||||
readonly signature?: string | null
|
||||
}
|
||||
|
||||
// The first non-empty signature and format win; a later delta may carry the
|
||||
// signature for text that streamed earlier.
|
||||
const mergeDetailIdentity = (previous: DetailIdentity, current: DetailIdentity) => {
|
||||
const signature = mergeDetailValue(previous.signature, current.signature)
|
||||
const format = mergeDetailValue(previous.format, current.format)
|
||||
return {
|
||||
...(signature === undefined ? {} : { signature }),
|
||||
...(format === undefined ? {} : { format }),
|
||||
}
|
||||
}
|
||||
|
||||
const mergeDetailValue = <T>(previous: T | undefined, current: T | undefined) =>
|
||||
const mergeDetailValue = (previous: unknown, current: unknown) =>
|
||||
previous || current || (previous !== undefined ? previous : current)
|
||||
|
||||
const conflictingReasoningDetails = (previous: DetailIdentity, current: DetailIdentity) =>
|
||||
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
|
||||
conflictingDetailValue(previous.id, current.id) ||
|
||||
conflictingDetailValue(previous.index, current.index) ||
|
||||
conflictingDetailValue(previous.format, current.format) ||
|
||||
@@ -1014,7 +929,7 @@ const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||
const reasoningMetadata = (
|
||||
providerMetadataKey: string,
|
||||
field: ParserState["reasoningField"],
|
||||
details?: ReadonlyArray<ReasoningDetail>,
|
||||
details?: ReadonlyArray<unknown>,
|
||||
) => ({
|
||||
[providerMetadataKey]: {
|
||||
...(field ? { reasoningField: field } : {}),
|
||||
@@ -1077,16 +992,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}
|
||||
|
||||
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||
const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details)
|
||||
? knownReasoningDetails(delta.reasoning_details)
|
||||
: undefined
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
|
||||
const text = detailDelta?.length
|
||||
? (detailText(detailDelta, reasoningTextObserved) ?? reasoning?.text)
|
||||
: reasoning?.text
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
reasoningDetailsObserved &&
|
||||
@@ -1182,7 +1092,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
finishReason,
|
||||
lifecycle,
|
||||
reasoningField,
|
||||
reasoningTextObserved,
|
||||
reasoningDetails: state.reasoningDetails,
|
||||
reasoningDetailsObserved,
|
||||
reasoningEmitted,
|
||||
@@ -1263,7 +1172,6 @@ export const protocol = Protocol.make({
|
||||
toolCallEvents: [],
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningField: request.model.compatibility?.reasoningField,
|
||||
reasoningTextObserved: false,
|
||||
reasoningDetails: [],
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
ImageModel,
|
||||
GeneratedImage,
|
||||
ImageResponse,
|
||||
type ImageInput,
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
|
||||
const ADAPTER = "openai-images"
|
||||
const NAME = "OpenAI Images"
|
||||
const PROVIDER = ProviderID.make("openai")
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/images/generations"
|
||||
export const EDIT_PATH = "/images/edits"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
/** Provider-native options. Common fields (`n`, `size`, `format`, `images`, `mask`) live on the request. */
|
||||
export type OpenAIImageOptions = {
|
||||
readonly mask?: ImageInput
|
||||
readonly n?: number
|
||||
readonly size?: OpenAIImageString<
|
||||
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||
>
|
||||
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||
readonly moderation?: OpenAIImageString<"auto" | "low">
|
||||
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||
readonly outputCompression?: number
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = ImageRequestFor<OpenAIImageOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response schema
|
||||
// ---------------------------------------------------------------------------
|
||||
export type OpenAIImageBody = Record<string, unknown> & {
|
||||
readonly model: string
|
||||
readonly prompt: string
|
||||
}
|
||||
|
||||
const OpenAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
@@ -55,143 +59,196 @@ const OpenAIImageResponse = Schema.Struct({
|
||||
),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Multipart field names the route owns; `http.body` overlays cannot smuggle replacements for them. */
|
||||
const RESERVED_FORM_FIELDS = new Set(["model", "prompt", "image", "image[]", "images", "mask"])
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { outputCompression, ...native } = options
|
||||
return { output_compression: outputCompression, ...native }
|
||||
}
|
||||
|
||||
const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
|
||||
|
||||
const isInline = (asset: Media.Asset) => asset.inline() !== undefined
|
||||
|
||||
const reference = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
|
||||
Effect.map((item) => (item.type === "ref" ? { file_id: item.value } : { image_url: item.value })),
|
||||
)
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request: Request) {
|
||||
const images = request.images ?? []
|
||||
const mask = request.mask
|
||||
if (mask !== undefined && images.length === 0)
|
||||
return yield* ProviderShared.invalidRequest("An OpenAI image mask requires at least one input image")
|
||||
const fields = mergeJsonRecords(
|
||||
{ n: request.n, size: request.size, output_format: request.format },
|
||||
nativeOptions(request.providerOptions),
|
||||
request.http?.body,
|
||||
)
|
||||
|
||||
// Owned bytes go through multipart edits; remote URLs and file IDs use the JSON edits body instead.
|
||||
if (images.length > 0 && images.every(isInline) && (mask === undefined || isInline(mask))) {
|
||||
const form = new FormData()
|
||||
form.append("model", request.model.id)
|
||||
form.append("prompt", request.prompt)
|
||||
Object.entries(fields ?? {}).forEach(([key, value]) => {
|
||||
if (RESERVED_FORM_FIELDS.has(key)) return
|
||||
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
|
||||
})
|
||||
const uploads = yield* Effect.forEach(images, (image) => MediaInput.inlineBytes(ADAPTER, image))
|
||||
uploads.forEach((data, index) =>
|
||||
form.append("image[]", MediaInput.blob(data, images[index].mediaType), `image-${index}`),
|
||||
)
|
||||
if (mask !== undefined)
|
||||
form.append("mask", MediaInput.blob(yield* MediaInput.inlineBytes(ADAPTER, mask), mask.mediaType), "mask")
|
||||
return MediaProtocol.multipart(form)
|
||||
const { mask: _, outputFormat, outputCompression, ...native } = options
|
||||
return {
|
||||
output_format: outputFormat,
|
||||
output_compression: outputCompression,
|
||||
...native,
|
||||
}
|
||||
|
||||
const references = yield* Effect.forEach(images, reference)
|
||||
const maskReference = mask === undefined ? undefined : yield* reference(mask)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
images: references.length === 0 ? undefined : references,
|
||||
mask: maskReference,
|
||||
},
|
||||
fields,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const requestedFormat = (body: MediaProtocol.Body) => {
|
||||
if (body.type === "binary") return undefined
|
||||
const value = body.type === "json" ? body.value.output_format : body.value.get("output_format")
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
const decodeResponse = Effect.fn("OpenAIImages.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.DecodeContext<Request>,
|
||||
) {
|
||||
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, OpenAIImageResponse)(response)
|
||||
const decoded = output.value
|
||||
const format = decoded.output_format ?? requestedFormat(context.body) ?? "png"
|
||||
const mediaType = `image/${format}`
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
const providerMetadata =
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } }
|
||||
if (item.b64_json)
|
||||
return MediaInput.decodedAsset(output.invalid, `${NAME} result ${index}`, item.b64_json, mediaType, {
|
||||
info: { format },
|
||||
providerMetadata,
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<OpenAIImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||
const mask = request.options?.mask
|
||||
if (mask !== undefined && (request.images?.length ?? 0) === 0)
|
||||
return yield* ImageInputs.invalid("An OpenAI image mask requires at least one input image")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const sourceImages = request.images ?? []
|
||||
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url)
|
||||
return Effect.undefined
|
||||
})
|
||||
if (item.url) return Effect.succeed(Media.url(item.url, { mediaType, info: { format }, providerMetadata }))
|
||||
return Effect.fail(output.invalid(`${NAME} result ${index} has neither image data nor a URL`))
|
||||
const multipartMask =
|
||||
mask === undefined
|
||||
? undefined
|
||||
: mask.type === "bytes"
|
||||
? { data: mask.data, mediaType: mask.mediaType }
|
||||
: mask.type === "url"
|
||||
? yield* ImageInputs.decodeDataUrl(mask.url)
|
||||
: undefined
|
||||
const useMultipart =
|
||||
sourceImages.length > 0 &&
|
||||
multipartImages.every((image) => image !== undefined) &&
|
||||
(mask === undefined || multipartMask !== undefined)
|
||||
const path = sourceImages.length === 0 ? PATH : EDIT_PATH
|
||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query)
|
||||
|
||||
if (useMultipart) {
|
||||
const form = new FormData()
|
||||
form.append("model", request.model.id)
|
||||
form.append("prompt", request.prompt)
|
||||
Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
|
||||
if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key)) return
|
||||
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
|
||||
})
|
||||
multipartImages.forEach((image, index) => {
|
||||
if (image === undefined) return
|
||||
form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`)
|
||||
})
|
||||
if (multipartMask !== undefined)
|
||||
form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask")
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: "[multipart/form-data]",
|
||||
headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)),
|
||||
)
|
||||
return yield* parseResponse(response, request.options, http?.body)
|
||||
}
|
||||
|
||||
const references = sourceImages.map((image) => {
|
||||
if (image.type === "bytes") return { image_url: ImageInputs.dataUrl(image) }
|
||||
if (image.type === "url") return { image_url: image.url }
|
||||
if (image.type === "file-id") return { file_id: image.id }
|
||||
return undefined
|
||||
})
|
||||
if (references.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid("OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const maskReference =
|
||||
mask === undefined
|
||||
? undefined
|
||||
: mask.type === "bytes"
|
||||
? { image_url: ImageInputs.dataUrl(mask) }
|
||||
: mask.type === "url"
|
||||
? { image_url: mask.url }
|
||||
: mask.type === "file-id"
|
||||
? { file_id: mask.id }
|
||||
: undefined
|
||||
if (mask !== undefined && maskReference === undefined)
|
||||
return yield* ImageInputs.invalid("OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
images: references.length === 0 ? undefined : references,
|
||||
mask: maskReference,
|
||||
},
|
||||
nativeOptions(request.options),
|
||||
http?.body,
|
||||
) as OpenAIImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
return yield* parseResponse(response, request.options, http?.body)
|
||||
}),
|
||||
}
|
||||
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
||||
}
|
||||
|
||||
const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
options: OpenAIImageOptions | undefined,
|
||||
overlay: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "OpenAI Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OpenAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("OpenAI Images returned an invalid response", cause)),
|
||||
)
|
||||
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
|
||||
const format =
|
||||
decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png")
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError((cause) => output.invalid(`OpenAI Images result ${index} contains invalid base64 data`, cause)),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
mediaType: `image/${format}`,
|
||||
data,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (item.url)
|
||||
return Effect.succeed(
|
||||
new GeneratedImage({
|
||||
mediaType: `image/${format}`,
|
||||
data: item.url,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(output.invalid(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* output.invalid(`${NAME} returned no images`)
|
||||
if (images.length === 0) return yield* output.invalid("OpenAI Images returned no images")
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
decoded.usage === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: "tokens",
|
||||
input: decoded.usage.input_tokens,
|
||||
output: decoded.usage.output_tokens,
|
||||
total: decoded.usage.total_tokens,
|
||||
details: { openai: decoded.usage },
|
||||
},
|
||||
: new Usage({
|
||||
inputTokens: decoded.usage.input_tokens,
|
||||
outputTokens: decoded.usage.output_tokens,
|
||||
totalTokens: decoded.usage.total_tokens,
|
||||
providerMetadata: { openai: decoded.usage },
|
||||
}),
|
||||
providerMetadata: { openai: { outputFormat: format } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["aspectRatio", "seed"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
ImageModel.fromRoute<OpenAIImageOptions>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH),
|
||||
},
|
||||
input,
|
||||
)
|
||||
const imageBlob = (data: Uint8Array, mediaType: string) => {
|
||||
const buffer = new ArrayBuffer(data.byteLength)
|
||||
new Uint8Array(buffer).set(data)
|
||||
return new Blob([buffer], { type: mediaType })
|
||||
}
|
||||
|
||||
export const OpenAIImages = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
|
||||
@@ -6,9 +6,7 @@ import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
|
||||
import { resolveEffortUpdates } from "../effort-updates.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
@@ -96,15 +94,9 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
OpenResponses.InputItem,
|
||||
OpenAIResponsesHostedToolItem,
|
||||
OpenResponses.ConfigurationUpdate,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
context_management: Schema.optional(
|
||||
@@ -127,7 +119,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
@@ -141,14 +133,6 @@ const adapter = {
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
|
||||
const supportsEffortUpdates = (request: LLMRequest) => {
|
||||
if (request.providerOptions?.contextManagement !== undefined) return false
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
|
||||
}
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
const native = tool.native?.openai
|
||||
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
|
||||
@@ -205,22 +189,18 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const updates = resolveEffortUpdates(request, options.reasoningEffort)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return yield* decodeBody({
|
||||
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
|
||||
...(yield* OpenResponses.lowerConversation(request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
|
||||
tool_choice:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: (OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined)),
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,7 +295,6 @@ export const protocol = Protocol.make({
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
supportsEffortUpdates,
|
||||
})
|
||||
|
||||
const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords, type MediaUsage } from "../schema/index.js"
|
||||
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
|
||||
import { SpeechStream } from "./utils/speech-stream.js"
|
||||
|
||||
const ADAPTER = "openai-speech"
|
||||
const NAME = "OpenAI Speech"
|
||||
const PROVIDER = ProviderID.make("openai")
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/audio/speech"
|
||||
/** `pcm` is raw 24 kHz, 16-bit signed little-endian mono samples without a header. */
|
||||
const PCM_SAMPLE_RATE = 24000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OpenAISpeechOptions = Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<OpenAISpeechOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Streaming event schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SpeechStreamEvent = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("speech.audio.delta"), audio: Schema.Uint8ArrayFromBase64 }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("speech.audio.done"),
|
||||
usage: Schema.optional(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
])
|
||||
|
||||
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, SpeechStreamEvent)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface State extends SpeechStream.Audio {
|
||||
readonly done: boolean
|
||||
readonly usage?: MediaUsage
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `sse` is not supported for `tts-1` or `tts-1-hd`; those models stream the raw audio body instead.
|
||||
const supportsSse = (model: string) => !/^tts-1(-hd)?(-|$)/.test(model)
|
||||
|
||||
const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
input: request.text,
|
||||
voice: request.voice,
|
||||
instructions: request.instructions,
|
||||
response_format: request.format,
|
||||
speed: request.speed,
|
||||
stream_format: request.mode === "stream" && supportsSse(request.model.id) ? "sse" : undefined,
|
||||
},
|
||||
request.providerOptions,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isSse = (body: MediaProtocol.Body) => body.type === "json" && body.value.stream_format === "sse"
|
||||
|
||||
const onEvent = Effect.fn("OpenAISpeech.onEvent")(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "speech.audio.delta") return SpeechStream.delta(state, event.audio)
|
||||
const usage = event.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
done: true,
|
||||
usage:
|
||||
usage === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: "tokens" as const,
|
||||
input: usage.input_tokens,
|
||||
output: usage.output_tokens,
|
||||
total: usage.total_tokens,
|
||||
details: { openai: usage },
|
||||
},
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
})
|
||||
|
||||
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
|
||||
if (isSse(context.body) && !state.done) return Effect.fail(MediaProtocol.incomplete(ADAPTER))
|
||||
const format = context.request.format ?? "mp3"
|
||||
return SpeechStream.finish(ADAPTER, state, {
|
||||
...(format === "pcm" ? SpeechStream.pcm("pcm_s16le", PCM_SAMPLE_RATE) : SpeechStream.container(format)),
|
||||
usage: state.usage,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["language", "timestamps"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => (isSse(context.body) ? Framing.sse.frame(bytes) : bytes),
|
||||
initial: () => ({ chunks: [], done: false }),
|
||||
step: SpeechStream.step(onEvent),
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
SpeechModel.fromRoute<OpenAISpeechOptions, string | Uint8Array, State>(
|
||||
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
|
||||
input,
|
||||
)
|
||||
|
||||
export const OpenAISpeech = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords, type MediaUsage } from "../schema/index.js"
|
||||
import {
|
||||
TranscriptionFinishEvent,
|
||||
TranscriptionModel,
|
||||
TranscriptionSegmentEvent,
|
||||
TranscriptionTextDeltaEvent,
|
||||
type TranscriptionEvent,
|
||||
type TranscriptionRequestFor,
|
||||
type TranscriptionSegment,
|
||||
} from "../transcription.js"
|
||||
import { mediaTypeExtension } from "../utils/media-type.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
const ADAPTER = "openai-transcription"
|
||||
const NAME = "OpenAI Transcription"
|
||||
const PROVIDER = ProviderID.make("openai")
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/audio/transcriptions"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OpenAITranscriptionOptions = {
|
||||
readonly chunking_strategy?:
|
||||
| "auto"
|
||||
| {
|
||||
readonly type: "server_vad"
|
||||
readonly prefix_padding_ms?: number
|
||||
readonly silence_duration_ms?: number
|
||||
readonly threshold?: number
|
||||
}
|
||||
readonly include?: ReadonlyArray<"logprobs">
|
||||
readonly keywords?: ReadonlyArray<string>
|
||||
readonly known_speaker_names?: ReadonlyArray<string>
|
||||
readonly known_speaker_references?: ReadonlyArray<string>
|
||||
readonly temperature?: number
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = TranscriptionRequestFor<OpenAITranscriptionOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Streaming event schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Segment = Schema.Struct({
|
||||
text: Schema.String,
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
speaker: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const Usage = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tokens"),
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("duration"), seconds: Schema.Number }),
|
||||
])
|
||||
|
||||
const transcriptFields = {
|
||||
text: Schema.String,
|
||||
language: Schema.optional(Schema.String),
|
||||
languages: Schema.optional(Schema.Array(Schema.Struct({ code: Schema.String }))),
|
||||
duration: Schema.optional(Schema.Number),
|
||||
segments: Schema.optional(Schema.Array(Segment)),
|
||||
words: Schema.optional(
|
||||
Schema.Array(Schema.Struct({ word: Schema.String, start: Schema.Number, end: Schema.Number })),
|
||||
),
|
||||
usage: Schema.optional(Usage),
|
||||
}
|
||||
|
||||
const Event = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.delta"), delta: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.segment"), ...Segment.fields }),
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.done"), ...transcriptFields }),
|
||||
])
|
||||
const Transcript = Schema.Struct(transcriptFields)
|
||||
type Transcript = Schema.Schema.Type<typeof Transcript>
|
||||
|
||||
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, Event)
|
||||
const decodeTranscript = MediaProtocol.decodeFrame(ADAPTER, NAME, Transcript)
|
||||
|
||||
type Frame = string | { readonly document: string }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface State {
|
||||
readonly segments: Array<TranscriptionSegment>
|
||||
readonly transcript?: Transcript
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Capabilities {
|
||||
readonly stream: boolean
|
||||
readonly timestamps: ReadonlyArray<"segment" | "word">
|
||||
readonly diarize: boolean
|
||||
readonly languageField: "language" | "languages"
|
||||
}
|
||||
|
||||
const TRANSCRIBE: Capabilities = { stream: true, timestamps: [], diarize: false, languageField: "language" }
|
||||
|
||||
const capabilities = (model: string): Capabilities => {
|
||||
if (model.startsWith("whisper")) return { ...TRANSCRIBE, stream: false, timestamps: ["segment", "word"] }
|
||||
if (model.includes("diarize")) return { ...TRANSCRIBE, timestamps: ["segment"], diarize: true }
|
||||
// `gpt-transcribe` replaces `language` with `languages[]` and rejects both together.
|
||||
if (model.startsWith("gpt-transcribe")) return { ...TRANSCRIBE, languageField: "languages" }
|
||||
return TRANSCRIBE
|
||||
}
|
||||
|
||||
const unsupported = (operation: string, message: string) =>
|
||||
Effect.fail(ProviderShared.unsupportedOperation({ operation, provider: PROVIDER, route: ADAPTER, message }))
|
||||
|
||||
const validate = (request: MediaProtocol.Addressed<Request>, model: Capabilities) => {
|
||||
const id = request.model.id
|
||||
if (request.mode === "stream" && !model.stream)
|
||||
return unsupported("media.stream", `${id} does not stream; use Transcription.generate`)
|
||||
if (request.diarize === true && !model.diarize)
|
||||
return unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`)
|
||||
if (request.prompt !== undefined && model.diarize)
|
||||
return unsupported("media.prompt", `${id} does not accept a prompt`)
|
||||
if (
|
||||
request.timestamps === undefined ||
|
||||
request.timestamps === "none" ||
|
||||
model.timestamps.includes(request.timestamps)
|
||||
)
|
||||
return Effect.void
|
||||
return unsupported("media.timestamps", `${id} does not return ${request.timestamps} timestamps`)
|
||||
}
|
||||
|
||||
const RESERVED_FORM_FIELDS = new Set([
|
||||
"file",
|
||||
"model",
|
||||
"prompt",
|
||||
"language",
|
||||
"response_format",
|
||||
"timestamp_granularities",
|
||||
"stream",
|
||||
])
|
||||
|
||||
const appendField = (form: FormData, key: string, value: unknown) => {
|
||||
if (Array.isArray(value)) return value.forEach((item) => form.append(`${key}[]`, String(item)))
|
||||
form.append(key, typeof value === "object" && value !== null ? ProviderShared.encodeJson(value) : String(value))
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const model = capabilities(request.model.id)
|
||||
yield* validate(request, model)
|
||||
// The API detects the audio format from the upload's filename extension.
|
||||
const extension = mediaTypeExtension(request.audio.mediaType)
|
||||
if (extension === undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`${NAME} cannot name a ${request.audio.mediaType} upload; send mp3, mp4, m4a, wav, webm, ogg, or flac audio`,
|
||||
)
|
||||
const audio = yield* MediaInput.inlineBytes(ADAPTER, request.audio)
|
||||
const responseFormat = model.diarize
|
||||
? "diarized_json"
|
||||
: request.timestamps === undefined || request.timestamps === "none"
|
||||
? undefined
|
||||
: "verbose_json"
|
||||
const native = Object.entries(mergeJsonRecords(request.providerOptions, request.http?.body) ?? {}).filter(
|
||||
([key]) => !RESERVED_FORM_FIELDS.has(key),
|
||||
)
|
||||
const fields = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
language: model.languageField === "language" ? request.language : undefined,
|
||||
languages: model.languageField === "languages" && request.language !== undefined ? [request.language] : undefined,
|
||||
prompt: request.prompt,
|
||||
response_format: responseFormat,
|
||||
timestamp_granularities: responseFormat === "verbose_json" ? [request.timestamps] : undefined,
|
||||
// Diarizing audio longer than 30 seconds requires a chunking strategy.
|
||||
chunking_strategy: model.diarize ? "auto" : undefined,
|
||||
stream: request.mode === "stream" ? true : undefined,
|
||||
},
|
||||
Object.fromEntries(native),
|
||||
)
|
||||
const form = new FormData()
|
||||
form.append("file", MediaInput.blob(audio, request.audio.mediaType), `audio.${extension}`)
|
||||
Object.entries(fields ?? {}).forEach(([key, value]) => appendField(form, key, value))
|
||||
return MediaProtocol.multipart(form)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegment => ({
|
||||
text: value.text.trim(),
|
||||
startSeconds: value.start,
|
||||
endSeconds: value.end,
|
||||
speaker: value.speaker,
|
||||
})
|
||||
|
||||
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "transcript.text.done") return [{ ...state, transcript: event }, []] as const
|
||||
if (event.type === "transcript.text.delta")
|
||||
return [state, event.delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta: event.delta })]] as const
|
||||
const next = segment(event)
|
||||
state.segments.push(next)
|
||||
return [state, [TranscriptionSegmentEvent.make({ segment: next })]] as const
|
||||
})
|
||||
|
||||
const step = (state: State, frame: Frame) =>
|
||||
typeof frame === "string"
|
||||
? onEvent(state, frame)
|
||||
: decodeTranscript(frame.document).pipe(Effect.map((transcript) => [{ ...state, transcript }, []] as const))
|
||||
|
||||
const usage = (value: Transcript["usage"]): MediaUsage | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (value.type === "duration") return { type: "seconds", seconds: value.seconds }
|
||||
return {
|
||||
type: "tokens",
|
||||
input: value.input_tokens,
|
||||
output: value.output_tokens,
|
||||
total: value.total_tokens,
|
||||
details: { openai: value },
|
||||
}
|
||||
}
|
||||
|
||||
const finish = (state: State) => {
|
||||
const transcript = state.transcript
|
||||
if (transcript === undefined) return Effect.fail(MediaProtocol.incomplete(ADAPTER))
|
||||
const segments = transcript.segments?.map(segment) ?? state.segments
|
||||
return Effect.succeed([
|
||||
TranscriptionFinishEvent.make({
|
||||
text: transcript.text,
|
||||
segments: segments.length === 0 ? undefined : segments,
|
||||
words: transcript.words?.map((word) => ({ text: word.word, startSeconds: word.start, endSeconds: word.end })),
|
||||
language: (transcript.language ?? transcript.languages?.[0]?.code)?.toLowerCase(),
|
||||
durationSeconds: transcript.duration,
|
||||
usage: usage(transcript.usage),
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame, State>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["speakers"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) =>
|
||||
context.request.mode === "stream"
|
||||
? Framing.sse.frame(bytes)
|
||||
: Framing.document.frame(bytes).pipe(Stream.map((document) => ({ document }))),
|
||||
initial: () => ({ segments: [] }),
|
||||
step,
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
TranscriptionModel.fromRoute<OpenAITranscriptionOptions, Frame, State>(
|
||||
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
|
||||
input,
|
||||
)
|
||||
|
||||
export const OpenAITranscription = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalArray, optionalNull } from "./shared.js"
|
||||
|
||||
const ADAPTER = "runway-video"
|
||||
const NAME = "Runway"
|
||||
const PROVIDER = ProviderID.make("runway")
|
||||
export const DEFAULT_BASE_URL = "https://api.dev.runwayml.com/v1"
|
||||
/** Every Runway request must pin the API version. */
|
||||
export const API_VERSION = "2024-11-06"
|
||||
export const TEXT_TO_VIDEO_PATH = "/text_to_video"
|
||||
export const IMAGE_TO_VIDEO_PATH = "/image_to_video"
|
||||
export const VIDEO_TO_VIDEO_PATH = "/video_to_video"
|
||||
export const TASKS_PATH = "/tasks"
|
||||
/** Output URLs are valid for 24–48 hours; the asset carries the conservative bound. */
|
||||
const OUTPUT_RETENTION = Duration.hours(24)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RunwayVideoString<Known extends string> = Known | (string & {})
|
||||
|
||||
/**
|
||||
* Provider-native options. Common fields lower to Runway's names: `aspectRatio` → `ratio` (Runway expects pixel
|
||||
* ratios such as `1280:720` for most models), `durationSeconds` → `duration`, `audio`, `negativePrompt`,
|
||||
* `resolution`, `references`, and `frames` → `promptImage`.
|
||||
*/
|
||||
export type RunwayVideoOptions = {
|
||||
readonly contentModeration?: { readonly publicFigureThreshold?: RunwayVideoString<"auto" | "low"> }
|
||||
readonly outputFormat?: RunwayVideoString<"mp4" | "prores" | "png_sequence">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<RunwayVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Token = Schema.Struct({ taskID: Schema.String })
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const Cost = Schema.Struct({ credits: Schema.Number })
|
||||
|
||||
const StartResponse = Schema.Struct({ id: Schema.String })
|
||||
|
||||
const Task = Schema.Struct({
|
||||
status: Schema.String,
|
||||
progress: optionalNull(Schema.Number),
|
||||
output: optionalArray(Schema.String),
|
||||
failure: optionalNull(Schema.String),
|
||||
failureCode: optionalNull(Schema.String),
|
||||
cost: Schema.optional(Cost),
|
||||
estimatedCost: Schema.optional(Cost),
|
||||
})
|
||||
|
||||
const STATUS = {
|
||||
PENDING: "queued",
|
||||
THROTTLED: "queued",
|
||||
RUNNING: "running",
|
||||
SUCCEEDED: "completed",
|
||||
FAILED: "failed",
|
||||
CANCELLED: "cancelled",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Runway accepts HTTPS URLs, `runway://` upload URIs, and data URIs, all as one string.
|
||||
const mediaUri = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(Effect.map((reference) => reference.value))
|
||||
|
||||
const fromRequest = Effect.fn("RunwayVideo.fromRequest")(function* (request: Request) {
|
||||
const first = request.frames?.first === undefined ? undefined : yield* mediaUri(request.frames.first)
|
||||
const last = request.frames?.last === undefined ? undefined : yield* mediaUri(request.frames.last)
|
||||
const promptImage = [
|
||||
...(first === undefined ? [] : [{ uri: first, position: "first" }]),
|
||||
...(last === undefined ? [] : [{ uri: last, position: "last" }]),
|
||||
]
|
||||
const videoUri = request.video === undefined ? undefined : yield* mediaUri(request.video)
|
||||
const references = yield* Effect.forEach(request.references ?? [], (asset) =>
|
||||
mediaUri(asset).pipe(Effect.map((uri) => ({ uri }))),
|
||||
)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
promptText: request.prompt,
|
||||
promptImage: promptImage.length === 0 ? undefined : promptImage,
|
||||
videoUri,
|
||||
references: references.length === 0 ? undefined : references,
|
||||
ratio: request.aspectRatio,
|
||||
duration: request.durationSeconds,
|
||||
resolution: request.resolution,
|
||||
audio: request.audio,
|
||||
negativePrompt: request.negativePrompt,
|
||||
seed: request.seed,
|
||||
},
|
||||
request.providerOptions,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: { taskID: value.id },
|
||||
snapshot: { id: value.id, status: "queued" },
|
||||
}))
|
||||
|
||||
const decodeTask = MediaProtocol.decodeJson(ADAPTER, NAME, Task)
|
||||
|
||||
const decodeStatus = Effect.fn("RunwayVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeTask(response)
|
||||
const status = yield* MediaProtocol.status(STATUS, output.value.status, output)
|
||||
return { id: context.token.taskID, status, progress: output.value.progress ?? undefined }
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeTask(response)
|
||||
const task = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, task.status, output)
|
||||
if (status === "failed") {
|
||||
const code = task.failureCode ?? undefined
|
||||
const message = `${NAME} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}`
|
||||
// Runway failure codes are dotted paths; every moderation outcome carries a SAFETY segment.
|
||||
if (code !== undefined && /(^|\.)SAFETY(\.|$)/.test(code)) return yield* output.contentPolicy(message)
|
||||
return yield* output.ended("failed", message)
|
||||
}
|
||||
if (status === "cancelled")
|
||||
return yield* output.ended("cancelled", `${NAME} task ${context.token.taskID} was cancelled`)
|
||||
if (status !== "completed") return yield* output.invalid(`${NAME} task ${context.token.taskID} has not finished`)
|
||||
const urls = task.output ?? []
|
||||
if (urls.length === 0) return yield* output.invalid(`${NAME} task succeeded without any output`)
|
||||
return new VideoResponse({
|
||||
videos: yield* Effect.forEach(urls, (url) =>
|
||||
MediaProtocol.expiringUrl(url, OUTPUT_RETENTION, { mediaType: "video/mp4" }),
|
||||
),
|
||||
usage: task.cost === undefined ? undefined : { type: "credits", credits: task.cost.credits },
|
||||
providerMetadata: {
|
||||
runway: {
|
||||
taskId: context.token.taskID,
|
||||
estimatedCredits: task.estimatedCost?.credits,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const taskPath = (token: Token) => `${TASKS_PATH}/${token.taskID}`
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
unsupported: ["n"],
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: taskPath, decode: decodeStatus },
|
||||
result: { path: taskPath, decode: decodeResult },
|
||||
cancel: { method: "DELETE", path: taskPath },
|
||||
})
|
||||
|
||||
const startPath = (request: Request) => {
|
||||
if (request.video !== undefined) return VIDEO_TO_VIDEO_PATH
|
||||
if (request.frames?.first !== undefined || request.frames?.last !== undefined) return IMAGE_TO_VIDEO_PATH
|
||||
return TEXT_TO_VIDEO_PATH
|
||||
}
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<RunwayVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
headers: { "X-Runway-Version": API_VERSION },
|
||||
path: ({ request }) => startPath(request),
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const RunwayVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Media } from "../media.js"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
UnsupportedOperationError,
|
||||
AIError,
|
||||
HttpContext,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolDefinition,
|
||||
@@ -28,16 +29,6 @@ const isJson = Schema.is(Schema.Json)
|
||||
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
|
||||
export const lenient = <const S extends Schema.Top>(schema: S) =>
|
||||
Schema.optionalKey(
|
||||
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
|
||||
)
|
||||
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
|
||||
export const knownString = <Known extends string>() =>
|
||||
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
|
||||
expected: "string",
|
||||
})
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
@@ -178,55 +169,24 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
|
||||
export const parseToolInput = (route: string, name: string, raw: string) =>
|
||||
parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`)
|
||||
|
||||
/** Inline view or a typed `InvalidRequest` for routes that cannot fetch URLs or dereference provider refs. */
|
||||
export const requireInlineMedia = (route: string, asset: Media.Asset): Effect.Effect<Media.Inline, AIError> => {
|
||||
const inline = asset.inline()
|
||||
return inline ? Effect.succeed(inline) : Effect.fail(inlineRequired(route, asset))
|
||||
export interface NormalizedMedia {
|
||||
readonly mime: string
|
||||
readonly base64: string
|
||||
readonly dataUrl: string
|
||||
}
|
||||
|
||||
export const inlineRequired = (route: string, asset: Media.Asset) =>
|
||||
invalidRequest(
|
||||
`${route} requires inline media (bytes or base64); ${asset.source.type} sources must be materialized first`,
|
||||
)
|
||||
|
||||
/** The remote URL of a `url` asset, for protocols that accept `http(s)` references natively. */
|
||||
export const mediaUrl = (asset: Media.Asset) => (asset.source.type === "url" ? asset.source.url : undefined)
|
||||
|
||||
export type MediaReference = { readonly type: "dataUrl" | "url" | "ref"; readonly value: string }
|
||||
|
||||
/**
|
||||
* The one string a provider can address an asset by: inline payloads as a data URL, `url` sources as their URL, and
|
||||
* this provider's own `ref` as its id. Other providers' refs are never forwarded and fail typed; omit `provider` for
|
||||
* APIs with no file handles at all.
|
||||
*/
|
||||
export const mediaReference = (
|
||||
asset: Media.Asset,
|
||||
provider: ProviderID | undefined,
|
||||
label: string,
|
||||
): Effect.Effect<MediaReference, AIError> => {
|
||||
const inline = asset.inline()
|
||||
if (inline) return Effect.succeed({ type: "dataUrl", value: inline.dataUrl })
|
||||
const url = mediaUrl(asset)
|
||||
if (url) return Effect.succeed({ type: "url", value: url })
|
||||
if (provider !== undefined && asset.source.type === "ref" && asset.source.provider === provider)
|
||||
return Effect.succeed({ type: "ref", value: asset.source.id })
|
||||
const accepted = provider === undefined ? "" : `, and ${provider} references`
|
||||
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}`))
|
||||
export const normalizeMedia = (part: MediaPart): NormalizedMedia => {
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
if (typeof part.data !== "string") {
|
||||
const base64 = Buffer.from(part.data).toString("base64")
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}` }
|
||||
}
|
||||
if (!part.data.startsWith("data:")) return { mime, base64: part.data, dataUrl: `data:${mime};base64,${part.data}` }
|
||||
return { mime, base64: part.data.slice(part.data.indexOf(",") + 1), dataUrl: part.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a tool-result file into a `MediaPart`. Tool files carry either a data URL, an `http(s)` URL, or raw base64 in
|
||||
* `uri`; the declared `mime` wins over any data-URL prefix so tool authors control the type the model sees.
|
||||
*/
|
||||
export const toolFileMedia = (item: Tool.FileContent): MediaPart => {
|
||||
const parsed = Media.parseDataUrl(item.uri)
|
||||
const asset = parsed
|
||||
? Media.from({ ...parsed.source, mediaType: item.mime })
|
||||
: /^https?:\/\//.test(item.uri)
|
||||
? Media.url(item.uri, { mediaType: item.mime })
|
||||
: Media.base64(item.uri, item.mime)
|
||||
return Message.media(asset, { filename: item.name })
|
||||
}
|
||||
export const normalizeToolFile = (part: Tool.FileContent) =>
|
||||
normalizeMedia({ type: "media", mediaType: part.mime, data: part.uri, filename: part.name })
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
@@ -253,11 +213,11 @@ export const errorText = (error: unknown) => {
|
||||
|
||||
/**
|
||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||
* decoder, optionally filters named events, and drops empty and bare `null`
|
||||
* events. `[DONE]` is dropped by default or retained for protocols that use it
|
||||
* as their stream boundary. Retry control events are ignored without
|
||||
* interrupting the stream. Decoder failures become provider output errors so
|
||||
* the public error channel stays `AIError`.
|
||||
* decoder, optionally filters named events, and drops empty events. `[DONE]`
|
||||
* is dropped by default or retained for protocols that use it as their stream
|
||||
* boundary. Retry control events are ignored without interrupting the stream.
|
||||
* Decoder failures become provider output errors so the public error channel
|
||||
* stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (
|
||||
bytes: Stream.Stream<Uint8Array, AIError>,
|
||||
@@ -287,10 +247,6 @@ export const sseFraming = (
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
// Some OpenAI-compatible proxies serialize an empty flush as a bare
|
||||
// `data: null`, between events or after `[DONE]`. No protocol has a
|
||||
// null event, so it carries nothing and must not abort the stream.
|
||||
event.data !== "null" &&
|
||||
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.map((event) => event.data),
|
||||
@@ -359,6 +315,34 @@ export const flattenToolRequest = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
|
||||
route: string,
|
||||
name: string,
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const http = new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
|
||||
const body = yield* response.text.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route,
|
||||
message: `Failed to read the ${name} response`,
|
||||
http,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return {
|
||||
body,
|
||||
invalid: (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({ route, message, body, http, cause }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
toolChoice: NonNullable<LLMRequest["toolChoice"]>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import type { CacheHint } from "../../schema/index.js"
|
||||
import { newBreakpoints, ttlBucket } from "./cache.js"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
|
||||
|
||||
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
|
||||
// after the content the caller wants treated as a cacheable prefix. Bedrock
|
||||
@@ -13,46 +13,24 @@ export const CachePointBlock = Schema.Struct({
|
||||
})
|
||||
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
|
||||
|
||||
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
|
||||
|
||||
// These legacy Claude releases support explicit caching, but only for five minutes.
|
||||
const CLAUDE_5M = [
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
]
|
||||
|
||||
// Callers share the four-breakpoint budget across system, messages, and tools.
|
||||
// Callers pass a shared counter through every `block()` call site so the
|
||||
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
|
||||
export const BEDROCK_BREAKPOINT_CAP = 4
|
||||
|
||||
export const breakpoints = (modelID: string) => {
|
||||
// Substring matching also handles regional prefixes and model-bearing ARNs.
|
||||
const short = CLAUDE_5M.some((id) => modelID.includes(id))
|
||||
return {
|
||||
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
|
||||
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
|
||||
// Other model families use implicit caching where available.
|
||||
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
|
||||
ttl1h: !short,
|
||||
}
|
||||
}
|
||||
export type Breakpoints = ReturnType<typeof breakpoints>
|
||||
export type { Breakpoints } from "./cache.js"
|
||||
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
|
||||
|
||||
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
|
||||
|
||||
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
if (!breakpoints.supported) return undefined
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
}
|
||||
|
||||
export * as BedrockCache from "./bedrock-cache.js"
|
||||
|
||||
@@ -57,27 +57,8 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
|
||||
},
|
||||
})
|
||||
|
||||
function documentName(filename: string | undefined, names: Set<string>) {
|
||||
const base =
|
||||
(filename ?? "")
|
||||
.replace(/\.[^.]*$/, "")
|
||||
.replace(/[^a-zA-Z0-9 ()[\]-]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 200)
|
||||
.trim() || "document"
|
||||
let name = base
|
||||
// Converse requires labels to be unique across the entire request, including tool results.
|
||||
for (let index = 2; names.has(name); index++) {
|
||||
const suffix = ` ${index}`
|
||||
name = `${base.slice(0, 200 - suffix.length).trimEnd()}${suffix}`
|
||||
}
|
||||
names.add(name)
|
||||
return name
|
||||
}
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const media = yield* ProviderShared.requireInlineMedia("Bedrock Converse", part.media)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
|
||||
@@ -91,30 +72,21 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
// get an image-specific error so the caller knows it's a format-support issue,
|
||||
// not a kind-detection issue.
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) {
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
return [{ image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock]
|
||||
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`Bedrock Converse does not support image media type ${part.media.mediaType}`,
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
||||
if (documentFormat) {
|
||||
const name = documentName(part.filename, documentNames)
|
||||
const block = documentBlock(name, documentFormat, yield* mediaBase64(part))
|
||||
return part.filename !== undefined && part.filename !== name
|
||||
? [
|
||||
{
|
||||
text: `Attached file ${ProviderShared.encodeJson(part.filename)} has document label ${ProviderShared.encodeJson(name)}.`,
|
||||
},
|
||||
block,
|
||||
]
|
||||
: [block]
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.media.mediaType}`)
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
export * as BedrockMedia from "./bedrock-media.js"
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Effect, Schema, type Stream } from "effect"
|
||||
import type { Media } from "../../media.js"
|
||||
import { Framing } from "../../route/framing.js"
|
||||
import type { MediaProtocol } from "../../route/media-protocol.js"
|
||||
import { AIError, ContentPolicyError, ProviderID, type MediaUsage, type ProviderMetadata } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
import { MediaInput } from "./media-input.js"
|
||||
|
||||
const PROVIDER = ProviderID.make("google")
|
||||
|
||||
const UsageMetadata = Schema.Struct({
|
||||
promptTokenCount: Schema.optional(Schema.Number),
|
||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||
totalTokenCount: Schema.optional(Schema.Number),
|
||||
})
|
||||
type UsageMetadata = Schema.Schema.Type<typeof UsageMetadata>
|
||||
|
||||
export const chunk = <const Part extends Schema.Top>(part: Part) =>
|
||||
Schema.Struct({
|
||||
candidates: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.optional(Schema.Struct({ parts: Schema.optional(Schema.Array(part)) })),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
promptFeedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
usageMetadata: Schema.optional(UsageMetadata),
|
||||
modelVersion: Schema.optional(Schema.String),
|
||||
responseId: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
interface Chunk {
|
||||
readonly candidates?: ReadonlyArray<{ readonly finishReason?: string }>
|
||||
readonly promptFeedback?: { readonly blockReason?: string; readonly blockReasonMessage?: string }
|
||||
readonly usageMetadata?: UsageMetadata
|
||||
readonly modelVersion?: string
|
||||
readonly responseId?: string
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
readonly usage?: UsageMetadata
|
||||
readonly finishReason?: string
|
||||
readonly modelVersion?: string
|
||||
readonly responseId?: string
|
||||
}
|
||||
|
||||
export const track = <State extends Metadata>(state: State, chunk: Chunk): State => ({
|
||||
...state,
|
||||
usage: chunk.usageMetadata ?? state.usage,
|
||||
finishReason: chunk.candidates?.[0]?.finishReason ?? state.finishReason,
|
||||
modelVersion: chunk.modelVersion ?? state.modelVersion,
|
||||
responseId: chunk.responseId ?? state.responseId,
|
||||
})
|
||||
|
||||
export const blocked = (name: string, chunk: Chunk, frame: string) => {
|
||||
const feedback = chunk.promptFeedback
|
||||
if (feedback?.blockReason === undefined) return undefined
|
||||
return new AIError({
|
||||
reason: new ContentPolicyError({
|
||||
message: `${name} blocked the request (${feedback.blockReason})${
|
||||
feedback.blockReasonMessage === undefined ? "" : `: ${feedback.blockReasonMessage}`
|
||||
}`,
|
||||
body: frame,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const usage = (usage: UsageMetadata | undefined): MediaUsage | undefined =>
|
||||
usage === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: "tokens",
|
||||
input: usage.promptTokenCount,
|
||||
output: usage.candidatesTokenCount,
|
||||
total: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
|
||||
details: { google: usage },
|
||||
}
|
||||
|
||||
export const providerMetadata = (state: Metadata): ProviderMetadata => ({
|
||||
google: { finishReason: state.finishReason, modelVersion: state.modelVersion, responseId: state.responseId },
|
||||
})
|
||||
|
||||
export const path = (model: string, mode: MediaProtocol.Mode) =>
|
||||
mode === "stream" ? `/models/${model}:streamGenerateContent?alt=sse` : `/models/${model}:generateContent`
|
||||
|
||||
// `generateContent` answers with one document shaped exactly like a streamed chunk, so it is a single frame.
|
||||
export const frames = (bytes: Stream.Stream<Uint8Array, AIError>, mode: MediaProtocol.Mode) =>
|
||||
mode === "stream" ? Framing.sse.frame(bytes) : Framing.document.frame(bytes)
|
||||
|
||||
// Gemini does not fetch public URLs; inline payloads and Gemini Files references are the accepted inputs.
|
||||
export const mediaPart = (
|
||||
route: string,
|
||||
asset: Media.Asset,
|
||||
): Effect.Effect<
|
||||
| { readonly fileData: { readonly mimeType: string; readonly fileUri: string } }
|
||||
| { readonly inlineData: { readonly mimeType: string; readonly data: string } },
|
||||
AIError
|
||||
> => {
|
||||
const fileUri = MediaInput.refID(asset, PROVIDER)
|
||||
if (fileUri !== undefined) return Effect.succeed({ fileData: { mimeType: asset.mediaType, fileUri } })
|
||||
return ProviderShared.requireInlineMedia(route, asset).pipe(
|
||||
Effect.map((media) => ({ inlineData: { mimeType: media.mime, data: media.base64 } })),
|
||||
)
|
||||
}
|
||||
|
||||
export * as GeminiGenerateContent from "./gemini-generate-content.js"
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Effect, Encoding } from "effect"
|
||||
import type { ImageInput } from "../../image.js"
|
||||
import { InvalidRequestError, AIError } from "../../schema/index.js"
|
||||
|
||||
const invalid = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({ message, cause }),
|
||||
})
|
||||
|
||||
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
|
||||
`data:${input.mediaType};base64,${Encoding.encodeBase64(input.data)}`
|
||||
|
||||
export const decodeDataUrl = (
|
||||
url: string,
|
||||
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
||||
if (!url.startsWith("data:")) return Effect.undefined
|
||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||
if (!match) return Effect.fail(invalid("Image data URLs must contain a MIME type and base64 data"))
|
||||
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||
Effect.mapError((cause) => invalid("Image data URL contains invalid base64 data", cause)),
|
||||
Effect.map((data) => ({ mediaType: match[1], data })),
|
||||
)
|
||||
}
|
||||
|
||||
export const invalidImageInput = invalid
|
||||
|
||||
export const ImageInputs = {
|
||||
dataUrl,
|
||||
decodeDataUrl,
|
||||
invalid: invalidImageInput,
|
||||
} as const
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Effect, Encoding } from "effect"
|
||||
import { Media } from "../../media.js"
|
||||
import type { MediaProtocol } from "../../route/media-protocol.js"
|
||||
import type { AIError, ProviderID } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
/** Owned bytes for multipart uploads; decodes `base64` sources and rejects remote sources. */
|
||||
export const inlineBytes = (route: string, asset: Media.Asset): Effect.Effect<Uint8Array, AIError> => {
|
||||
if (asset.source.type === "bytes") return Effect.succeed(asset.source.data)
|
||||
const inline = asset.inline()
|
||||
if (!inline) return Effect.fail(ProviderShared.inlineRequired(route, asset))
|
||||
return Effect.fromResult(Encoding.decodeBase64(inline.base64)).pipe(
|
||||
Effect.mapError((cause) => ProviderShared.invalidRequest(`${route} media contains invalid base64 data`, cause)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Copied because `BlobPart` requires a plain `ArrayBuffer`. */
|
||||
export const blob = (data: Uint8Array, mediaType: string) => {
|
||||
const buffer = new ArrayBuffer(data.byteLength)
|
||||
new Uint8Array(buffer).set(data)
|
||||
return new Blob([buffer], { type: mediaType })
|
||||
}
|
||||
|
||||
const isScalar = (value: unknown): value is string | number | boolean =>
|
||||
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
|
||||
export const query = (route: string, values: Record<string, unknown>): Effect.Effect<MediaProtocol.Query, AIError> => {
|
||||
const entries = Object.entries(values).filter(([, value]) => value !== undefined)
|
||||
const invalid = entries.find(([, value]) => !isScalar(value) && !(Array.isArray(value) && value.every(isScalar)))
|
||||
if (invalid !== undefined)
|
||||
return Effect.fail(ProviderShared.invalidRequest(`${route} cannot send "${invalid[0]}" as a query parameter`))
|
||||
return Effect.succeed(
|
||||
Object.fromEntries(entries.map(([key, value]) => [key, Array.isArray(value) ? value.map(String) : String(value)])),
|
||||
)
|
||||
}
|
||||
|
||||
/** Provider file handle when the ref belongs to this provider; refs from other providers are never forwarded. */
|
||||
export const refID = (asset: Media.Asset, provider: ProviderID) =>
|
||||
asset.source.type === "ref" && asset.source.provider === provider ? asset.source.id : undefined
|
||||
|
||||
/** Decode a provider's base64 output once into an owned `bytes` asset, sniffing the type when it is not declared. */
|
||||
export const decodedAsset = (
|
||||
invalid: (message: string, cause?: unknown) => AIError,
|
||||
label: string,
|
||||
data: string,
|
||||
mediaType: string | undefined,
|
||||
options?: Media.AssetOptions,
|
||||
) =>
|
||||
Effect.fromResult(Encoding.decodeBase64(data)).pipe(
|
||||
Effect.mapError((cause) => invalid(`${label} contains invalid base64 data`, cause)),
|
||||
Effect.map((bytes) => Media.bytes(bytes, mediaType, options)),
|
||||
)
|
||||
|
||||
export * as MediaInput from "./media-input.js"
|
||||
@@ -1,11 +0,0 @@
|
||||
// Responses image items can omit output_format, including when PNG/JPEG was requested.
|
||||
export const mediaType = (data: Uint8Array, format?: string) => {
|
||||
if (format !== undefined) return `image/${format}`
|
||||
if (data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "image/png"
|
||||
if (data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg"
|
||||
if (new TextDecoder().decode(data.slice(0, 4)) === "RIFF" && new TextDecoder().decode(data.slice(8, 12)) === "WEBP")
|
||||
return "image/webp"
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
export * as MetaImage from "./meta-image.js"
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export const valid = /^[A-Za-z0-9]{9}$/
|
||||
|
||||
export const hash = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
export const normalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
// Reserve valid IDs before projecting any history, including IDs encountered later.
|
||||
const used = new Set(ids.filter((id) => valid.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (valid.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hash(id)
|
||||
while (used.has(candidate)) candidate = hash(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
export * as MistralToolID from "./mistral-tool-id.js"
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Schema } from "effect"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
import { lenient } from "../shared.js"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
|
||||
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
|
||||
(value): value is ReasoningEffort => typeof value === "string",
|
||||
{ title: "ReasoningEffort" },
|
||||
)
|
||||
|
||||
export const TextVerbosities = ["low", "medium", "high"] as const
|
||||
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
|
||||
@@ -50,22 +54,21 @@ export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
|
||||
export const Options = Schema.Struct({
|
||||
store: lenient(Schema.Boolean),
|
||||
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: lenient(Schema.String),
|
||||
streamOptions: lenient(StreamOptions),
|
||||
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: lenient(ReasoningEffort),
|
||||
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: lenient(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: lenient(TextVerbositySchema),
|
||||
serviceTier: lenient(ServiceTierSchema),
|
||||
truncation: lenient(TruncationSchema),
|
||||
allowedTools: lenient(AllowedTools),
|
||||
maxToolCalls: lenient(Schema.Int),
|
||||
parallelToolCalls: lenient(Schema.Boolean),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -73,10 +76,11 @@ export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownSync(Options)
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = decodeOptions(request.providerOptions ?? {})
|
||||
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
|
||||
if (!input) return {}
|
||||
return {
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
|
||||
@@ -11,13 +11,11 @@ import {
|
||||
mergeJsonRecords,
|
||||
} from "../../schema/index.js"
|
||||
import type { CompactOperation } from "../../route/client.js"
|
||||
import { stripEffortUpdates } from "../../effort-updates.js"
|
||||
import { Endpoint } from "../../route/endpoint.js"
|
||||
import { RequestExecutor } from "../../route/executor.js"
|
||||
import { HttpTransport } from "../../route/transport/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
|
||||
import { Media } from "../../media.js"
|
||||
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
@@ -77,8 +75,7 @@ const Response = Schema.Struct({
|
||||
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
|
||||
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
|
||||
const route = request.model.route
|
||||
// The standalone compaction endpoint rejects histories containing configuration updates.
|
||||
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
|
||||
const native = yield* OpenResponses.lowerConversation(request, adapter)
|
||||
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
@@ -158,22 +155,20 @@ function toMessage(item: (typeof Response.Type.output)[number], model: LLMReques
|
||||
if (part.type === "input_image")
|
||||
return {
|
||||
type: "media",
|
||||
media: replayMedia(part.image_url, "image/*"),
|
||||
data: part.image_url,
|
||||
mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
const data = part.file_url === undefined ? part.file_data : part.file_url
|
||||
return {
|
||||
type: "media",
|
||||
media: replayMedia(part.file_url === undefined ? part.file_data : part.file_url, "application/octet-stream"),
|
||||
data,
|
||||
filename: part.filename,
|
||||
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Replayed compaction items carry either a data URL or a remote URL; the data URL's own type wins when present. */
|
||||
const replayMedia = (value: string, fallbackType: string) =>
|
||||
Media.parseDataUrl(value) ??
|
||||
(/^https?:\/\//.test(value) ? Media.url(value, { mediaType: fallbackType }) : Media.base64(value, fallbackType))
|
||||
|
||||
export * as ResponsesCompaction from "./responses-compaction.js"
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { Media } from "../../media.js"
|
||||
import { MediaProtocol } from "../../route/media-protocol.js"
|
||||
import type { AIError, MediaUsage, ProviderID, ProviderMetadata } from "../../schema/index.js"
|
||||
import {
|
||||
SpeechAudioDeltaEvent,
|
||||
SpeechFinishEvent,
|
||||
SpeechTimestampsEvent,
|
||||
type SpeechEvent,
|
||||
type SpeechVoice,
|
||||
} from "../../speech.js"
|
||||
import { concatBytes } from "../../utils/bytes.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
export interface Audio {
|
||||
/** Appended in place: the route creates fresh state for each response through `initial`. */
|
||||
readonly chunks: Array<Uint8Array>
|
||||
}
|
||||
|
||||
export type StepResult<State> = readonly [State, ReadonlyArray<SpeechEvent>]
|
||||
|
||||
/** Empty chunks (keep-alive records) emit nothing. */
|
||||
export const delta = <State extends Audio>(state: State, chunk: Uint8Array): StepResult<State> => {
|
||||
if (chunk.length === 0) return [state, []]
|
||||
state.chunks.push(chunk)
|
||||
return [state, [SpeechAudioDeltaEvent.make({ chunk })]]
|
||||
}
|
||||
|
||||
export const step =
|
||||
<State extends Audio>(onRecord: (state: State, frame: string) => Effect.Effect<StepResult<State>, AIError>) =>
|
||||
(state: State, frame: string | Uint8Array) =>
|
||||
typeof frame === "string" ? onRecord(state, frame) : Effect.succeed(delta(state, frame))
|
||||
|
||||
export const timestamps = (
|
||||
texts: ReadonlyArray<string>,
|
||||
starts: ReadonlyArray<number>,
|
||||
ends: ReadonlyArray<number>,
|
||||
): ReadonlyArray<SpeechEvent> =>
|
||||
texts.length === 0
|
||||
? []
|
||||
: [
|
||||
SpeechTimestampsEvent.make({
|
||||
items: texts.map((text, index) => ({ text, startSeconds: starts[index] ?? 0, endSeconds: ends[index] ?? 0 })),
|
||||
}),
|
||||
]
|
||||
|
||||
export const voiceID = (voice: SpeechVoice | undefined) => (typeof voice === "object" ? voice.id : voice)
|
||||
|
||||
const CONTAINER_MEDIA_TYPES: Readonly<Record<string, string>> = {
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
opus: "audio/ogg",
|
||||
aac: "audio/aac",
|
||||
flac: "audio/flac",
|
||||
}
|
||||
|
||||
export const container = (format: string, sampleRate?: number) => ({
|
||||
mediaType: CONTAINER_MEDIA_TYPES[format],
|
||||
info: { format, sampleRate },
|
||||
})
|
||||
|
||||
const PCM_MEDIA_TYPES = {
|
||||
pcm_s16le: "audio/pcm",
|
||||
pcm_f32le: "audio/pcm",
|
||||
pcm_mulaw: "audio/mulaw",
|
||||
pcm_alaw: "audio/alaw",
|
||||
} as const
|
||||
|
||||
export type PcmEncoding = keyof typeof PCM_MEDIA_TYPES
|
||||
|
||||
export const pcm = (encoding: PcmEncoding, sampleRate: number | undefined, mediaType?: string) => ({
|
||||
mediaType: mediaType ?? PCM_MEDIA_TYPES[encoding],
|
||||
info: { format: "pcm", encoding, sampleRate, channels: 1 },
|
||||
})
|
||||
|
||||
export const sampleRate = (mediaType: string | undefined) => {
|
||||
const rate = /rate=(\d+)/i.exec(mediaType ?? "")?.[1]
|
||||
return rate === undefined ? undefined : Number(rate)
|
||||
}
|
||||
|
||||
export const unsupportedFormat = (provider: ProviderID, route: string, message: string) =>
|
||||
ProviderShared.unsupportedOperation({ operation: "media.format", provider, route, message })
|
||||
|
||||
/** A declared `mediaType` wins over sniffing: headerless PCM can start with bytes that look like an MPEG frame sync. */
|
||||
export const finish = (
|
||||
route: string,
|
||||
state: Audio,
|
||||
output: {
|
||||
readonly mediaType: string | undefined
|
||||
readonly info?: Media.Info
|
||||
readonly usage?: MediaUsage
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
readonly detail?: string
|
||||
},
|
||||
): Effect.Effect<ReadonlyArray<SpeechEvent>, AIError> => {
|
||||
if (state.chunks.length === 0)
|
||||
return Effect.fail(
|
||||
MediaProtocol.frameError(
|
||||
route,
|
||||
`The provider returned no audio${output.detail === undefined ? "" : ` (${output.detail})`}`,
|
||||
),
|
||||
)
|
||||
return Effect.succeed([
|
||||
SpeechFinishEvent.make({
|
||||
audio: Media.bytes(concatBytes(state.chunks), output.mediaType, { info: output.info }),
|
||||
usage: output.usage,
|
||||
providerMetadata: output.providerMetadata,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
export const headerUsage = (type: "characters" | "credits", value: string | undefined): MediaUsage | undefined => {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) return undefined
|
||||
return type === "credits" ? { type, credits: amount } : { type, characters: amount }
|
||||
}
|
||||
|
||||
export * as SpeechStream from "./speech-stream.js"
|
||||
@@ -1,38 +1,61 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
const ADAPTER = "xai-images"
|
||||
const NAME = "xAI Images"
|
||||
const PROVIDER = ProviderID.make("xai")
|
||||
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||
export const PATH = "/images/generations"
|
||||
export const EDIT_PATH = "/images/edits"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type XAIImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
/** Provider-native options. Common fields (`n`, `aspectRatio`, `images`) live on the request. */
|
||||
export type XAIImageOptions = {
|
||||
readonly n?: number
|
||||
readonly aspectRatio?: XAIImageString<
|
||||
| "1:1"
|
||||
| "3:4"
|
||||
| "4:3"
|
||||
| "9:16"
|
||||
| "16:9"
|
||||
| "2:3"
|
||||
| "3:2"
|
||||
| "9:19.5"
|
||||
| "19.5:9"
|
||||
| "9:20"
|
||||
| "20:9"
|
||||
| "1:2"
|
||||
| "2:1"
|
||||
| "auto"
|
||||
>
|
||||
readonly aspect_ratio?: XAIImageString<
|
||||
| "1:1"
|
||||
| "3:4"
|
||||
| "4:3"
|
||||
| "9:16"
|
||||
| "16:9"
|
||||
| "2:3"
|
||||
| "3:2"
|
||||
| "9:19.5"
|
||||
| "19.5:9"
|
||||
| "9:20"
|
||||
| "20:9"
|
||||
| "1:2"
|
||||
| "2:1"
|
||||
| "auto"
|
||||
>
|
||||
readonly resolution?: XAIImageString<"1k" | "2k">
|
||||
readonly responseFormat?: XAIImageString<"url" | "b64_json">
|
||||
readonly response_format?: XAIImageString<"url" | "b64_json">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = ImageRequestFor<XAIImageOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response schema
|
||||
// ---------------------------------------------------------------------------
|
||||
type XAIImageBody = Record<string, unknown> & {
|
||||
readonly model: string
|
||||
readonly prompt: string
|
||||
}
|
||||
|
||||
const XAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
@@ -46,106 +69,120 @@ const XAIImageResponse = Schema.Struct({
|
||||
usage: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { responseFormat, ...native } = options
|
||||
return { response_format: responseFormat, ...native }
|
||||
const { aspectRatio, responseFormat, ...native } = options
|
||||
return {
|
||||
aspect_ratio: aspectRatio,
|
||||
response_format: responseFormat,
|
||||
...native,
|
||||
}
|
||||
}
|
||||
|
||||
const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
const reference = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
|
||||
Effect.map((item) =>
|
||||
item.type === "ref" ? { file_id: item.value } : { url: item.value, type: "image_url" as const },
|
||||
),
|
||||
)
|
||||
|
||||
const fromRequest = Effect.fn("XAIImages.fromRequest")(function* (request: Request) {
|
||||
const references = yield* Effect.forEach(request.images ?? [], reference)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
// xAI takes one edit source as `image` and several as `images`.
|
||||
image: references.length === 1 ? references[0] : undefined,
|
||||
images: references.length > 1 ? references : undefined,
|
||||
n: request.n,
|
||||
aspect_ratio: request.aspectRatio,
|
||||
},
|
||||
nativeOptions(request.providerOptions),
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeResponse = Effect.fn("XAIImages.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, XAIImageResponse)(response)
|
||||
const decoded = output.value
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
const providerMetadata =
|
||||
item.revised_prompt === undefined || item.revised_prompt === null
|
||||
? undefined
|
||||
: { xai: { revisedPrompt: item.revised_prompt } }
|
||||
if (item.b64_json)
|
||||
return MediaInput.decodedAsset(
|
||||
output.invalid,
|
||||
`${NAME} result ${index}`,
|
||||
item.b64_json,
|
||||
item.mime_type ?? undefined,
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<XAIImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const imageReferences = (request.images ?? []).map((image) => {
|
||||
if (image.type === "bytes") return { url: ImageInputs.dataUrl(image), type: "image_url" as const }
|
||||
if (image.type === "url") return { url: image.url, type: "image_url" as const }
|
||||
if (image.type === "file-id") return { file_id: image.id }
|
||||
return undefined
|
||||
})
|
||||
if (imageReferences.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid("xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
providerMetadata,
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
image: imageReferences.length === 1 ? imageReferences[0] : undefined,
|
||||
images: imageReferences.length > 1 ? imageReferences : undefined,
|
||||
},
|
||||
nativeOptions(request.options),
|
||||
http?.body,
|
||||
) as XAIImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const url = applyQuery(
|
||||
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${imageReferences.length === 0 ? PATH : EDIT_PATH}`,
|
||||
http?.query,
|
||||
)
|
||||
if (item.url)
|
||||
return Effect.succeed(Media.url(item.url, { mediaType: item.mime_type ?? undefined, providerMetadata }))
|
||||
return Effect.fail(output.invalid(`${NAME} result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* output.invalid(`${NAME} returned no images`)
|
||||
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||
// xAI reports image counts rather than tokens, seconds, or credits; the raw record stays in provider metadata.
|
||||
return new ImageResponse({
|
||||
images,
|
||||
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["mask", "size", "seed", "format"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
ImageModel.fromRoute<XAIImageOptions>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH),
|
||||
},
|
||||
input,
|
||||
)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "xAI Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(XAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("xAI Images returned an invalid response", cause)),
|
||||
)
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
const mediaType = item.mime_type ?? "application/octet-stream"
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
output.invalid(`xAI Images result ${index} contains invalid base64 data`, cause),
|
||||
),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
mediaType,
|
||||
data,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined || item.revised_prompt === null
|
||||
? undefined
|
||||
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (item.url)
|
||||
return Effect.succeed(
|
||||
new GeneratedImage({
|
||||
mediaType,
|
||||
data: item.url,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined || item.revised_prompt === null
|
||||
? undefined
|
||||
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(output.invalid(`xAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* output.invalid("xAI Images returned no images")
|
||||
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage: usage === undefined ? undefined : new Usage({ providerMetadata: { xai: usage } }),
|
||||
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||
})
|
||||
}),
|
||||
}
|
||||
return ImageModel.make<XAIImageOptions>({ id: input.id, provider: "xai", route, http: input.http })
|
||||
}
|
||||
|
||||
export const XAIImages = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
|
||||
const ADAPTER = "xai-video"
|
||||
const NAME = "xAI Video"
|
||||
const PROVIDER = ProviderID.make("xai")
|
||||
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||
export const PATH = "/videos/generations"
|
||||
export const EDIT_PATH = "/videos/edits"
|
||||
export const EXTEND_PATH = "/videos/extensions"
|
||||
export const STATUS_PATH = "/videos"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provider-native options. Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`,
|
||||
* `resolution`, `audio`) live on the request. `mode` selects the endpoint a `video` input is sent to.
|
||||
*/
|
||||
export type XAIVideoOptions = {
|
||||
readonly mode?: "edit" | "extend"
|
||||
readonly reference_audios?: ReadonlyArray<{ readonly voice_id: string }>
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<XAIVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Token = Schema.Struct({ requestID: Schema.String })
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({ request_id: Schema.String })
|
||||
|
||||
const VideoStatus = Schema.Struct({
|
||||
status: Schema.String,
|
||||
progress: optionalNull(Schema.Number),
|
||||
video: optionalNull(
|
||||
Schema.Struct({
|
||||
url: optionalNull(Schema.String),
|
||||
duration: optionalNull(Schema.Number),
|
||||
respect_moderation: optionalNull(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
error: optionalNull(
|
||||
Schema.Struct({
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
}),
|
||||
),
|
||||
model: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const STATUS = {
|
||||
pending: "running",
|
||||
done: "completed",
|
||||
failed: "failed",
|
||||
expired: "expired",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mediaInput = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
|
||||
Effect.map((reference) => (reference.type === "ref" ? { file_id: reference.value } : { url: reference.value })),
|
||||
)
|
||||
|
||||
const nativeOptions = (options: XAIVideoOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { mode: _mode, ...native } = options
|
||||
return native
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("XAIVideo.fromRequest")(function* (request: Request) {
|
||||
const image = request.frames?.first === undefined ? undefined : yield* mediaInput(request.frames.first)
|
||||
const lastFrame = request.frames?.last === undefined ? undefined : yield* mediaInput(request.frames.last)
|
||||
const video = request.video === undefined ? undefined : yield* mediaInput(request.video)
|
||||
const references = yield* Effect.forEach(request.references ?? [], mediaInput)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
image,
|
||||
last_frame: lastFrame,
|
||||
reference_images: references.length === 0 ? undefined : references,
|
||||
video,
|
||||
duration: request.durationSeconds,
|
||||
aspect_ratio: request.aspectRatio,
|
||||
resolution: request.resolution,
|
||||
generate_audio: request.audio,
|
||||
},
|
||||
nativeOptions(request.providerOptions),
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: { requestID: value.request_id },
|
||||
snapshot: { id: value.request_id, status: "running" },
|
||||
}))
|
||||
|
||||
// `progress` is undocumented but observed live as a 0..100 percentage (recorded cassette: 1 → 10 → 37 → 100).
|
||||
const fraction = (progress: number | null | undefined) =>
|
||||
progress !== undefined && progress !== null && progress >= 0 && progress <= 100 ? progress / 100 : undefined
|
||||
|
||||
const decodeVideoStatus = MediaProtocol.decodeJson(ADAPTER, NAME, VideoStatus)
|
||||
|
||||
const decodeStatus = Effect.fn("XAIVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeVideoStatus(response)
|
||||
const status = yield* MediaProtocol.status(STATUS, output.value.status, output)
|
||||
return { id: context.token.requestID, status, progress: fraction(output.value.progress) }
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeVideoStatus(response)
|
||||
const decoded = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
|
||||
if (status === "running") return yield* output.invalid(`${NAME} request ${context.token.requestID} has not finished`)
|
||||
if (status === "failed") {
|
||||
const code = decoded.error?.code ?? undefined
|
||||
const message = decoded.error?.message ?? undefined
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
`${NAME} generation failed${code === undefined ? "" : ` (${code})`}${message === undefined ? "" : `: ${message}`}`,
|
||||
)
|
||||
}
|
||||
if (status !== "completed")
|
||||
return yield* output.ended("expired", `${NAME} request ${context.token.requestID} expired`)
|
||||
// `respect_moderation: false` marks a filtered result; a URL may still be present, so report it as a notice.
|
||||
const notices =
|
||||
decoded.video?.respect_moderation === false
|
||||
? [{ type: "moderated" as const, message: `${NAME} flagged the generated video for moderation` }]
|
||||
: undefined
|
||||
const url = decoded.video?.url ?? undefined
|
||||
if (url === undefined && notices !== undefined)
|
||||
return yield* output.contentPolicy(`${NAME} withheld the video for moderation`)
|
||||
if (url === undefined) return yield* output.invalid(`${NAME} completed without a video URL`)
|
||||
const duration = decoded.video?.duration ?? undefined
|
||||
return new VideoResponse({
|
||||
videos: [
|
||||
Media.url(url, {
|
||||
mediaType: "video/mp4",
|
||||
info: duration === undefined ? undefined : { durationSeconds: duration },
|
||||
}),
|
||||
],
|
||||
notices,
|
||||
providerMetadata: { xai: { requestId: context.token.requestID, model: decoded.model ?? undefined } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const statusPath = (token: Token) => `${STATUS_PATH}/${token.requestID}`
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
unsupported: ["n", "seed", "negativePrompt"],
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: statusPath, decode: decodeStatus },
|
||||
result: { path: statusPath, decode: decodeResult },
|
||||
})
|
||||
|
||||
// A source video goes to `/videos/edits` unless `providerOptions.mode` asks for an extension.
|
||||
const startPath = (request: Request) => {
|
||||
if (request.video === undefined) return PATH
|
||||
return request.providerOptions?.mode === "extend" ? EXTEND_PATH : EDIT_PATH
|
||||
}
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<XAIVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => startPath(request),
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const XAIVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import type { LanguageModelCompatibility, LLMRequest } from "../schema/index.js"
|
||||
import { OpenAIChat } from "./openai-chat.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {})
|
||||
|
||||
export type OptionsInput = {
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly thinking?: {
|
||||
readonly type?: "enabled" | "disabled" | (string & {})
|
||||
/** False retains historical reasoning; omission preserves the endpoint's default. */
|
||||
readonly clear_thinking?: boolean
|
||||
}
|
||||
readonly toolStream?: boolean
|
||||
readonly doSample?: boolean
|
||||
readonly responseFormat?: { readonly type: "text" | "json_object" | (string & {}) }
|
||||
readonly requestID?: string
|
||||
readonly userID?: string
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(
|
||||
Schema.Struct({ type: Schema.optional(Schema.String), clear_thinking: Schema.optional(Schema.Boolean) }),
|
||||
),
|
||||
toolStream: Schema.optional(Schema.Boolean),
|
||||
doSample: Schema.optional(Schema.Boolean),
|
||||
responseFormat: Schema.optional(Schema.Struct({ type: Schema.String })),
|
||||
requestID: Schema.optional(Schema.String),
|
||||
userID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const Body = Schema.Struct({
|
||||
...OpenAIChat.bodyFields,
|
||||
thinking: Options.fields.thinking,
|
||||
do_sample: Options.fields.doSample,
|
||||
response_format: Options.fields.responseFormat,
|
||||
request_id: Options.fields.requestID,
|
||||
user_id: Options.fields.userID,
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("ZAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
|
||||
const body = yield* OpenAIChat.protocol.body.from(request)
|
||||
return {
|
||||
...body,
|
||||
thinking: options.thinking,
|
||||
// Tool streaming was introduced in GLM-4.6; older models must not receive the opt-in.
|
||||
tool_stream:
|
||||
options.toolStream ??
|
||||
(body.tools?.length && /^glm-(?:4\.[67]|5(?:[.-]|$))/i.test(request.model.id) ? true : undefined),
|
||||
do_sample: options.doSample,
|
||||
response_format: options.responseFormat,
|
||||
request_id: options.requestID,
|
||||
user_id: options.userID,
|
||||
}
|
||||
})
|
||||
|
||||
export const compatibility = {
|
||||
maxTokensField: "max_tokens",
|
||||
supportsStore: false,
|
||||
supportsStrictMode: false,
|
||||
reasoningField: "reasoning_content",
|
||||
zaiToolStream: false,
|
||||
} satisfies LanguageModelCompatibility
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "zai-chat",
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
export * as ZAIChat from "./zai-chat.js"
|
||||
@@ -1,34 +1,29 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import { mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
const ADAPTER = "zai-images"
|
||||
const NAME = "Z.ai Images"
|
||||
const PROVIDER = ProviderID.make("zai")
|
||||
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||
export const PATH = "/images/generations"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ZAIImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
/** Provider-native options. The common `size` field lives on the request. */
|
||||
export type ZAIImageOptions = {
|
||||
readonly size?: ZAIImageString<
|
||||
"1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
|
||||
>
|
||||
readonly quality?: ZAIImageString<"hd" | "standard">
|
||||
readonly userID?: string
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = ImageRequestFor<ZAIImageOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response schema
|
||||
// ---------------------------------------------------------------------------
|
||||
type ZAIImageBody = Record<string, unknown> & {
|
||||
readonly model: string
|
||||
readonly prompt: string
|
||||
}
|
||||
|
||||
const ZAIImageResponse = Schema.Struct({
|
||||
created: Schema.optional(Schema.Int),
|
||||
@@ -45,81 +40,84 @@ const ZAIImageResponse = Schema.Struct({
|
||||
),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { userID, ...native } = options
|
||||
return { user_id: userID, ...native }
|
||||
return {
|
||||
user_id: userID,
|
||||
...native,
|
||||
}
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("ZAIImages.fromRequest")(function* (request: Request) {
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{ model: request.model.id, prompt: request.prompt, size: request.size },
|
||||
nativeOptions(request.providerOptions),
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, ZAIImageResponse)(response)
|
||||
const decoded = output.value
|
||||
if (decoded.data.length === 0) return yield* output.invalid(`${NAME} returned no images`)
|
||||
const filters = decoded.content_filter ?? []
|
||||
return new ImageResponse({
|
||||
// Z.ai returns only URLs and no content type; the media type resolves when the asset is materialized.
|
||||
images: decoded.data.map((item) => Media.url(item.url)),
|
||||
// Z.ai reports applied content filters alongside a successful result; surface them instead of dropping them.
|
||||
notices:
|
||||
filters.length === 0
|
||||
? undefined
|
||||
: filters.map((filter) => ({
|
||||
type: "moderated" as const,
|
||||
message: `${NAME} applied a content filter${filter.role === undefined ? "" : ` for ${filter.role}`}${
|
||||
filter.level === undefined ? "" : ` at level ${filter.level}`
|
||||
}`,
|
||||
providerMetadata: { zai: filter },
|
||||
})),
|
||||
providerMetadata: {
|
||||
zai: {
|
||||
created: decoded.created,
|
||||
id: decoded.id,
|
||||
requestID: decoded.request_id,
|
||||
contentFilter: decoded.content_filter,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
unsupported: ["images", "mask", "n", "aspectRatio", "seed", "format"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
ImageModel.fromRoute<ZAIImageOptions>(
|
||||
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
|
||||
input,
|
||||
)
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute<ZAIImageOptions> = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||
if ((request.images?.length ?? 0) > 0)
|
||||
return yield* ImageInputs.invalid("Z.ai hosted image generation does not support image inputs")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const requestBody = mergeJsonRecords(
|
||||
{ model: request.model.id, prompt: request.prompt },
|
||||
nativeOptions(request.options),
|
||||
http?.body,
|
||||
) as ZAIImageBody
|
||||
const text = ProviderShared.encodeJson(requestBody)
|
||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "Z.ai Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ZAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("Z.ai Images returned an invalid response", cause)),
|
||||
)
|
||||
if (decoded.data.length === 0) return yield* output.invalid("Z.ai Images returned no images")
|
||||
return new ImageResponse({
|
||||
images: decoded.data.map(
|
||||
(item) =>
|
||||
new GeneratedImage({
|
||||
mediaType: "application/octet-stream",
|
||||
data: item.url,
|
||||
}),
|
||||
),
|
||||
providerMetadata: {
|
||||
zai: {
|
||||
created: decoded.created,
|
||||
id: decoded.id,
|
||||
requestID: decoded.request_id,
|
||||
contentFilter: decoded.content_filter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
}
|
||||
return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
|
||||
}
|
||||
|
||||
export const ZAIImages = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { LLMRequest } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "./anthropic-messages.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import type { ZAIChat } from "./zai-chat.js"
|
||||
|
||||
export type OptionsInput = {
|
||||
readonly effort?: ZAIChat.ReasoningEffort
|
||||
readonly thinking?: { readonly type: "enabled" | "adaptive" | "disabled" | (string & {}) }
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
effort: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
|
||||
})
|
||||
const Body = Schema.Struct({
|
||||
...AnthropicMessages.AnthropicMessagesBody.fields,
|
||||
thinking: Options.fields.thinking,
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("ZAIMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
|
||||
// Z.AI accepts enabled thinking without Anthropic's mandatory token budget.
|
||||
const body = yield* AnthropicMessages.protocol.body.from(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { ...request.providerOptions, thinking: undefined },
|
||||
}),
|
||||
)
|
||||
return { ...body, thinking: options.thinking }
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "zai-messages",
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
})
|
||||
|
||||
export * as ZAIMessages from "./zai-messages.js"
|
||||
@@ -59,15 +59,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
"insufficient_quota",
|
||||
"usage_not_included",
|
||||
"billing_error",
|
||||
"gousagelimiterror",
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -81,28 +73,9 @@ const SERVER_CODES = new Set([
|
||||
"serviceunavailableexception",
|
||||
])
|
||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||
// Azure OpenAI reports `content_filter` with `innererror.code` ResponsibleAIPolicyViolation.
|
||||
// OpenRouter tags provider failures with a typed `error_type`; its Responses skin also
|
||||
// emits `image_content_policy_violation` as the native code.
|
||||
const CONTENT_POLICY_CODES = new Set([
|
||||
"content_filter",
|
||||
"responsibleaipolicyviolation",
|
||||
"content_policy_violation",
|
||||
"image_content_policy_violation",
|
||||
"refusal",
|
||||
])
|
||||
// OpenCode Zen replaces upstream codes outside its allow-list but keeps the original
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
// under `invalid_request_error`.
|
||||
const CONTENT_POLICY_TEXT =
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
@@ -129,12 +102,9 @@ export interface ProviderFailure {
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
|
||||
const body = input.rawBody ?? ""
|
||||
const codes = [
|
||||
...providerCodes(input.data),
|
||||
...providerCodes(body),
|
||||
...providerCodes(input.message),
|
||||
...(GATEWAY_CODE_LABEL.exec(input.message)?.slice(1) ?? []),
|
||||
].map((code) => code.toLowerCase())
|
||||
const codes = [...providerCodes(input.data), ...providerCodes(body), ...providerCodes(input.message)].map((code) =>
|
||||
code.toLowerCase(),
|
||||
)
|
||||
// Scan the raw payload too so signals missing from the summary message
|
||||
// (e.g. overflow phrases nested in a JSON error body) still classify.
|
||||
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
|
||||
@@ -150,13 +120,8 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
return new InvalidRequestError({ ...details, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
|
||||
return new ContentPolicyError(details)
|
||||
if (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
@@ -176,12 +141,10 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
// Server codes and phrasing only decide when no HTTP status contradicts them:
|
||||
// gateways such as OpenCode Zen substitute `server_error` for codes they do
|
||||
// not forward, so a 4xx with a server code is still a rejected request.
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
@@ -197,24 +160,12 @@ function providerCodes(value: unknown) {
|
||||
const decoded = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
if (!isRecord(decoded)) return []
|
||||
const error = isRecord(decoded.error) ? decoded.error : undefined
|
||||
const inner = error && isRecord(error.innererror) ? error.innererror : undefined
|
||||
const metadata = error && isRecord(error.metadata) ? error.metadata : undefined
|
||||
const response = isRecord(decoded.response) ? decoded.response : undefined
|
||||
const responseError = response && isRecord(response.error) ? response.error : undefined
|
||||
const exception = isRecord(decoded.exception) ? decoded.exception : undefined
|
||||
return [
|
||||
decoded.code,
|
||||
decoded.error_type,
|
||||
error?.code,
|
||||
error?.type,
|
||||
error?.status,
|
||||
error?.error_type,
|
||||
inner?.code,
|
||||
metadata?.error_type,
|
||||
responseError?.code,
|
||||
response?.error_type,
|
||||
exception?.type,
|
||||
].filter((value): value is string => typeof value === "string")
|
||||
return [decoded.code, error?.code, error?.type, error?.status, responseError?.code, exception?.type].filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
|
||||
/**
|
||||
* Flat, serializable settings for `model(modelID, settings)`. Each entrypoint declares the connection keys it
|
||||
* reads; every other key is a request option for the route's protocol.
|
||||
*/
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Struct } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { AlibabaChat } from "../protocols/alibaba-chat.js"
|
||||
import { AlibabaMessages } from "../protocols/alibaba-messages.js"
|
||||
import { AlibabaResponses } from "../protocols/alibaba-responses.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("alibaba")
|
||||
|
||||
export type Region =
|
||||
| "ap-southeast-1"
|
||||
| "cn-beijing"
|
||||
| "cn-hongkong"
|
||||
| "us-east-1"
|
||||
| "eu-central-1"
|
||||
| "ap-northeast-1"
|
||||
| (string & {})
|
||||
export type ChatOptionsInput = AlibabaChat.OptionsInput
|
||||
export type MessagesOptionsInput = AlibabaMessages.OptionsInput
|
||||
export type ResponsesOptionsInput = AlibabaResponses.OptionsInput
|
||||
|
||||
type Location = AtLeastOne<{
|
||||
readonly region: Region
|
||||
/** Overrides the selected API's complete base URL, including its version prefix. */
|
||||
readonly baseURL: string
|
||||
}> & { readonly workspaceID?: string }
|
||||
|
||||
export type Config = Location &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
|
||||
}
|
||||
export type Settings<Options = ChatOptionsInput> = Location &
|
||||
ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
}
|
||||
|
||||
const hosts = new Map<string, string>([
|
||||
["ap-southeast-1", "dashscope-intl.aliyuncs.com"],
|
||||
["cn-beijing", "dashscope.aliyuncs.com"],
|
||||
["cn-hongkong", "cn-hongkong.dashscope.aliyuncs.com"],
|
||||
["us-east-1", "dashscope-us.aliyuncs.com"],
|
||||
])
|
||||
const chatRoute = Route.make({
|
||||
id: "alibaba-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "alibaba",
|
||||
protocol: AlibabaChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
const messagesRoute = Route.make({
|
||||
id: "alibaba-messages",
|
||||
provider: id,
|
||||
providerMetadataKey: "alibaba",
|
||||
protocol: AlibabaMessages.protocol,
|
||||
endpoint: Endpoint.path("/messages"),
|
||||
framing: Framing.sse,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
const responsesRoute = Route.make({
|
||||
id: "alibaba-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "alibaba",
|
||||
protocol: AlibabaResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses"),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const routes = [chatRoute, messagesRoute, responsesRoute]
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
const { apiKey: _key, auth: _auth, region, workspaceID, baseURL, ...rest } = input
|
||||
const host =
|
||||
region === undefined
|
||||
? undefined
|
||||
: workspaceID === undefined
|
||||
? hosts.get(region)
|
||||
: `${workspaceID}.${region}.maas.aliyuncs.com`
|
||||
if (baseURL === undefined) {
|
||||
if (region === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
|
||||
if (host === undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
})
|
||||
}
|
||||
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
|
||||
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
|
||||
const chat = (id: string | ModelID) =>
|
||||
chatRoute.with(common).model<ChatOptionsInput>({ id, compatibility: AlibabaChat.compatibility })
|
||||
const messages = (id: string | ModelID) =>
|
||||
messagesRoute
|
||||
.with({
|
||||
...opts,
|
||||
endpoint: { baseURL: baseURL ?? `https://${host}/apps/anthropic/v1` },
|
||||
})
|
||||
.model<MessagesOptionsInput>({ id, compatibility: { requireSignature: false } })
|
||||
const responses = (id: string | ModelID) => responsesRoute.with(common).model<ResponsesOptionsInput>({ id })
|
||||
return { id, model: chat, chat, messages, responses, configure }
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (id, input) =>
|
||||
fromSettings(input).chat(id)
|
||||
export const messagesModel: ProviderPackage.Definition<
|
||||
Settings<MessagesOptionsInput>,
|
||||
MessagesOptionsInput
|
||||
>["model"] = (id, input) => fromSettings(input).messages(id)
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings<ResponsesOptionsInput>,
|
||||
ResponsesOptionsInput
|
||||
>["model"] = (id, input) => fromSettings(input).responses(id)
|
||||
|
||||
function fromSettings(input: Settings<Config["providerOptions"]>) {
|
||||
const { body, ...rest } = input
|
||||
return configure({
|
||||
...rest,
|
||||
http: body === undefined ? undefined : { body },
|
||||
providerOptions: Struct.omit(rest, ["apiKey", "baseURL", "headers", "region", "workspaceID"]),
|
||||
})
|
||||
}
|
||||
|
||||
export const webSearch = () => hostedTool("web_search", "Search the web with Alibaba's hosted search tool.")
|
||||
export const webExtractor = () => hostedTool("web_extractor", "Extract web page content with Alibaba's hosted tool.")
|
||||
export const codeInterpreter = () => hostedTool("code_interpreter", "Execute code with Alibaba's hosted interpreter.")
|
||||
|
||||
function hostedTool(type: "web_search" | "web_extractor" | "code_interpreter", description: string) {
|
||||
return ToolDefinition.make({
|
||||
name: type,
|
||||
description,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
native: { alibaba: { type } },
|
||||
})
|
||||
}
|
||||
|
||||
export * as Alibaba from "./alibaba.js"
|
||||
@@ -1 +0,0 @@
|
||||
export { model, type Settings } from "../alibaba.js"
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { Alibaba } from "../alibaba.js"
|
||||
export { messagesModel as model } from "../alibaba.js"
|
||||
export type Settings = Alibaba.Settings<Alibaba.MessagesOptionsInput>
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { Alibaba } from "../alibaba.js"
|
||||
export { responsesModel as model } from "../alibaba.js"
|
||||
export type Settings = Alibaba.Settings<Alibaba.ResponsesOptionsInput>
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
@@ -23,25 +22,26 @@ export type Config = RouteDefaultsInput & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
@@ -79,12 +79,9 @@ const defaults = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
|
||||
})
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
@@ -108,29 +105,18 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const fromSettings = ({
|
||||
apiKey,
|
||||
auth,
|
||||
baseURL,
|
||||
body,
|
||||
credentials,
|
||||
headers,
|
||||
profile,
|
||||
region,
|
||||
topP,
|
||||
...providerOptions
|
||||
}: Settings) =>
|
||||
const fromSettings = (settings: Settings) =>
|
||||
configure({
|
||||
apiKey,
|
||||
auth,
|
||||
baseURL,
|
||||
credentials,
|
||||
generation: topP === undefined ? undefined : { topP },
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
profile,
|
||||
providerOptions,
|
||||
region,
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
})
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -39,9 +39,8 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
|
||||
if (auth === "sigv4" && apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user