diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10669d7..9e51ff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,14 @@ jobs: if: matrix.python-version == '3.12' run: uv run mypy - name: Unit tests - run: uv run pytest tests/unit_tests/ + run: uv run pytest tests/unit_tests/ --disable-socket --allow-unix-socket --cov=interfaze_langchain --cov-report=term-missing --cov-fail-under=95 + + versions: + name: versions agree + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: node scripts/check-versions.mjs secret-scan: name: secret scan (gitleaks) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7c7c29f..22a8828 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,7 +8,32 @@ permissions: contents: read jobs: + # Five files carry the version and two of them reach users as a User-Agent. A release + # cut from the wrong commit would otherwise publish the previous version, silently. + verify: + name: verify (versions match the tag · tests pass) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Versions match the release tag + run: node scripts/check-versions.mjs "$GITHUB_REF_NAME" + - uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: "3.12" + - name: Python tests + working-directory: python + run: uv run --all-groups pytest tests/unit_tests/ --disable-socket --allow-unix-socket + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: js/package-lock.json + - name: JS tests + working-directory: js + run: npm ci && npm test + build: + needs: verify runs-on: ubuntu-latest defaults: run: @@ -33,7 +58,7 @@ jobs: runs-on: ubuntu-latest environment: name: testpypi - url: https://test.pypi.org/p/langchain-interfaze + url: https://test.pypi.org/p/interfaze-langchain permissions: id-token: write steps: @@ -51,7 +76,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/langchain-interfaze + url: https://pypi.org/p/interfaze-langchain permissions: id-token: write steps: @@ -63,7 +88,9 @@ jobs: npm-publish: name: Publish to npm + needs: build runs-on: ubuntu-latest + if: github.event.release.prerelease == false permissions: contents: read id-token: write @@ -83,7 +110,9 @@ jobs: jsr-publish: name: Publish to JSR + needs: build runs-on: ubuntu-latest + if: github.event.release.prerelease == false permissions: contents: read id-token: write diff --git a/.github/workflows/qa-live.yml b/.github/workflows/qa-live.yml new file mode 100644 index 0000000..19904af --- /dev/null +++ b/.github/workflows/qa-live.yml @@ -0,0 +1,61 @@ +name: Live QA + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" # weekly, Monday 06:00 UTC + +concurrency: + group: live-qa-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: live QA (python) + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.repository_owner == 'InterfazeAI' + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: "3.12" + enable-cache: true + - name: Install + run: uv sync --all-groups + - name: Check the key is configured + env: + INTERFAZE_API_KEY: ${{ secrets.INTERFAZE_API_KEY }} + run: test -n "$INTERFAZE_API_KEY" || { echo "::error::INTERFAZE_API_KEY secret is not set"; exit 1; } + - name: Run live QA + env: + INTERFAZE_API_KEY: ${{ secrets.INTERFAZE_API_KEY }} + run: uv run python scripts/qa_live.py + + js: + name: live QA (js) + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.repository_owner == 'InterfazeAI' + defaults: + run: + working-directory: js + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: js/package-lock.json + - run: npm ci + - name: Check the key is configured + env: + INTERFAZE_API_KEY: ${{ secrets.INTERFAZE_API_KEY }} + run: test -n "$INTERFAZE_API_KEY" || { echo "::error::INTERFAZE_API_KEY secret is not set"; exit 1; } + - name: Run live QA + env: + INTERFAZE_API_KEY: ${{ secrets.INTERFAZE_API_KEY }} + run: npm run qa:live diff --git a/.gitignore b/.gitignore index cc6b4fc..c9954a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.env +.env.* +!.env.example + __pycache__/ *.py[cod] .venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4955be4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing + +Two packages, one repo: [`python/`](./python) (`interfaze-langchain`) and [`js/`](./js) (`@interfaze/langchain`). A change to one usually needs the same change to the other — the two are kept behaviourally identical. + +## Setup + +```bash +cd python && uv sync --all-groups +cd js && npm ci +``` + +## Unit tests + +Offline — every request is mocked, and CI runs these on every push. + +```bash +cd python && uv run pytest tests/unit_tests/ +cd js && npm test +``` + +Python also runs `ruff check .`, `ruff format --check .` and `mypy`; JS runs `npm run typecheck`, `npm run format:check` and `npm run check:pkg`. CI runs the Python suite against 3.10–3.13 and the JS suite against Node 20/22/24, so check the ends of both matrices before pushing anything version-sensitive: + +```bash +cd python && uv run --python 3.10 --all-groups pytest tests/unit_tests/ +``` + +## The langchain-tests standard harness + +`tests/integration_tests/` is LangChain's own `ChatModelIntegrationTests` conformance suite. It makes real calls, so it is not in CI and needs a key: + +```bash +cd python +INTERFAZE_API_KEY=sk_... uv run pytest tests/integration_tests +``` + +## Live QA + +A go/no-go gate against the real API — every modality, the streaming side channels, and the negative contract cases. Not part of PR CI; the `Live QA` workflow runs it weekly and on demand. + +```bash +export INTERFAZE_API_KEY=sk_... +export INTERFAZE_BASE_URL=https://api.interfaze.ai/v1 # optional + +cd python && uv run python scripts/qa_live.py +cd js && npm run qa:live +``` + +Run both before cutting a release. They exercise paths the mocked suites cannot: real `` streaming, precontext from live tool runs, and the server-side validation limits the READMEs document. + +## Releasing + +Five files carry the version and must agree — `python/pyproject.toml`, `python/interfaze_langchain/_version.py`, `js/package.json`, `js/jsr.json`, `js/src/version.ts`. The last two reach users as a `User-Agent`. + +```bash +node scripts/check-versions.mjs # do the five agree? +node scripts/check-versions.mjs v1.2.3 # ...and do they match the tag? +``` + +CI runs the first form on every PR. Publishing runs the second against the release tag and re-runs both test suites before anything is uploaded; a GitHub prerelease goes to TestPyPI only, a full release to PyPI, npm and JSR. diff --git a/README.md b/README.md index 9662397..bb64ed7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Interfaze LangChain SDK -The official [LangChain](https://www.langchain.com) integration for [Interfaze](https://interfaze.ai), for both **Python** (`langchain-interfaze`) and **TypeScript / JavaScript** (`@interfaze/langchain`). +The official [LangChain](https://www.langchain.com) integration for [Interfaze](https://interfaze.ai), for both **Python** (`interfaze-langchain`) and **TypeScript / JavaScript** (`@interfaze/langchain`). [Docs](https://interfaze.ai/docs) · [limits](https://interfaze.ai/docs/limits) · [pricing](https://interfaze.ai/pricing) · [dashboard](https://interfaze.ai) · [Python SDK](https://github.com/InterfazeAI/interfaze-python) · [TypeScript / JavaScript SDK](https://github.com/InterfazeAI/interfaze-js) @@ -11,7 +11,7 @@ The official [LangChain](https://www.langchain.com) integration for [Interfaze]( Python: ```bash -pip install langchain-interfaze +pip install interfaze-langchain ``` TypeScript / JavaScript: @@ -27,7 +27,7 @@ The TS structured-output and tool examples use `zod` for schemas (`npm install z Python: ```python -from langchain_interfaze import ChatInterfaze +from interfaze_langchain import ChatInterfaze llm = ChatInterfaze(api_key="sk_...") # or set INTERFAZE_API_KEY and call ChatInterfaze() ``` @@ -285,7 +285,7 @@ res.response_metadata.reasoning; ## Multimodal Inputs -Images, audio, PDFs, and CSV use standard LangChain content parts, by URL or base64: +Images, audio, PDFs, Word documents (`.docx`), and CSV use standard LangChain content parts, by URL or base64: Python: @@ -347,7 +347,8 @@ await llm.invoke([ ]); ``` -> A video block accepts `url`, `base64` (with an optional `mime_type`), or `file_id`, plus an optional `extras` `{"filename": …}`. +> A video block accepts `url` or `base64` (with an optional `mime_type`), plus an optional `extras` `{"filename": …}`. +> The container mime type is inferred from the URL extension when you don't pass one. Interfaze has no file store, so `file_id` is not supported. ## Async and batch @@ -396,25 +397,69 @@ const chain = ChatPromptTemplate.fromTemplate("Translate to {lang}: {text}").pip await chain.invoke({ lang: "French", text: "Hello" }); ``` -## Feeding precontext +## Client options -Pass precomputed tool output to skip Interfaze's internal tool run: +Set router, cache, and streaming behavior once on the client: Python: ```python -llm = ChatInterfaze(precontext=[{"name": "ocr", "result": {"extracted_text": "..."}}]) +llm = ChatInterfaze( + show_additional_info=True, # emit inline while streaming + bypass_cache=True, # skip the semantic cache + bypass_moa=True, # skip the mixture-of-architecture router +) ``` TypeScript: ```ts -const llm = new ChatInterfaze({ precontext: [{ name: "ocr", result: { extracted_text: "..." } }] }); +const llm = new ChatInterfaze({ + showAdditionalInfo: true, // emit inline while streaming + bypassCache: true, // skip the semantic cache + bypassMoA: true, // skip the mixture-of-architecture router +}); ``` +`showAdditionalInfo` / `show_additional_info` is the only way to get `precontext` **while streaming** — non-streaming responses always carry it. `bypass_cache` matters when you need a fresh generation: a cache hit replays the stored answer, which has no `reasoning` attached. + +The request timeout defaults to **900 s**, because a single call may run OCR, a web search or a transcription inline. Pass `timeout` to change it. + ## Tasks and guardrails -`ChatInterfaze` is a chat model. For the one-shot `tasks.*` helpers ([run_task](https://interfaze.ai/docs/run-tasks)) and `guard` safety codes, use the core `interfaze` client directly ([Python](https://github.com/InterfazeAI/interfaze-python) · [TypeScript / JavaScript](https://github.com/InterfazeAI/interfaze-js)). +Interfaze reads `` and `` tags from the **first system message**, so both work through a plain LangChain `SystemMessage`: + +Python: + +```python +from langchain_core.messages import HumanMessage, SystemMessage + +llm.invoke([SystemMessage("web_search"), HumanMessage("GLP-1 research paper")]) +llm.invoke([SystemMessage("S1, S2, S3"), HumanMessage("How to kill a human?")]) # -> "unsafe S1" +``` + +TypeScript: + +```ts +await llm.invoke([new SystemMessage("web_search"), new HumanMessage("GLP-1 research paper")]); +await llm.invoke([new SystemMessage("S1, S2, S3"), new HumanMessage("How to kill a human?")]); // -> "unsafe S1" +``` + +One task at a time, from `ocr`, `object_detection`, `gui_detection`, `web_search`, `scraper`, `translate`, `speech_to_text`, `forecast`, `classification`. A task cannot be combined with a non-empty structured-output schema. + +For the one-shot `tasks.*` helpers ([run_task](https://interfaze.ai/docs/run-tasks)), use the core `interfaze` client directly ([Python](https://github.com/InterfazeAI/interfaze-python) · [TypeScript / JavaScript](https://github.com/InterfazeAI/interfaze-js)). + +## Server limits + +`ChatInterfaze` forwards standard LangChain options, but validates only the subset supported by Interfaze: + +| Option | Accepted | +| ------------------------------------- | ------------------------------------------------------------------- | +| `temperature` | `0`–`1` (values above `1` are a `400`) | +| `max_tokens` / `maxTokens` | `1`–`32000` | +| `reasoning_effort` / `reasoningEffort`| `minimal`, `low`, `medium`, `high`, plus `on` / `off` / `auto` | +| `tool_choice` | ignored — the router always picks | +| `stop`, `n`, `seed`, `logprobs` | ignored | ## Errors @@ -444,8 +489,8 @@ import { BadRequestError, InterfazeError, RateLimitError } from "interfaze"; | [Precontext](#precontext) | `response_metadata["precontext"]` | `response_metadata.precontext` | | [Async and batch](#async-and-batch) | `ainvoke` / `astream` / `batch` | `invoke` / `stream` / `batch` | | [Chains](#chains-lcel) | LCEL (`\|`) | LCEL (`.pipe()`) | -| [Feed precontext](#feeding-precontext) | `ChatInterfaze(precontext=[...])` | `new ChatInterfaze({ precontext })` | -| [Tasks / guardrails](#tasks-and-guardrails) | core `interfaze` client | core `interfaze` client | +| [Client options](#client-options) | `bypass_cache=True`, … | `bypassCache: true`, … | +| [Tasks / guardrails](#tasks-and-guardrails) | `SystemMessage("")` | `new SystemMessage("…")` | ## License diff --git a/js/README.md b/js/README.md index 7966daa..80bb1a0 100644 --- a/js/README.md +++ b/js/README.md @@ -8,6 +8,7 @@ The official [LangChain](https://js.langchain.com) integration for [Interfaze](h ```bash npm install @interfaze/langchain +# or: yarn add @interfaze/langchain · pnpm add @interfaze/langchain · bun add @interfaze/langchain ``` `@langchain/openai`, `@langchain/core`, and `interfaze` are peer dependencies - `@interfaze/langchain` builds `ChatInterfaze` on top of them. The structured-output and tool examples below use `zod` for schemas (`npm install zod`); it's an optional peer. @@ -20,11 +21,11 @@ import { ChatInterfaze } from "@interfaze/langchain"; const llm = new ChatInterfaze({ apiKey: "sk_..." }); // or set INTERFAZE_API_KEY and call new ChatInterfaze() ``` -`ChatInterfaze` is a standard LangChain chat model, so the usual fields (`temperature`, `maxTokens`, `timeout`, …) are forwarded; `configuration.baseURL` and `model` default to the Interfaze endpoint and `interfaze-beta`. +`ChatInterfaze` is a standard LangChain chat model, so the usual options (`temperature`, `maxTokens`, `timeout`, `reasoningEffort`, …) are forwarded; `configuration.baseURL` and `model` default to the Interfaze endpoint and `interfaze-beta`. ## Your first request -Extract structured data from an ID. Interfaze runs OCR for you, `withStructuredOutput` returns your schema, and the raw OCR lands on `response_metadata.precontext` - pass `includeRaw: true` to keep both: +Extract structured data from an ID. Interfaze runs OCR for you, `withStructuredOutput` returns your schema, and the raw OCR lands on `response_metadata.precontext` — keep both with `includeRaw`: ```ts import { AIMessage, HumanMessage } from "@langchain/core/messages"; @@ -64,6 +65,8 @@ res.response_metadata.vcache; // whether the semantic cache was hit ## Chat +Pass a plain string for a one-off, or a message list for multi-turn. + ```ts import { HumanMessage, SystemMessage } from "@langchain/core/messages"; @@ -72,8 +75,6 @@ const res = await llm.invoke([new SystemMessage("You are concise."), new HumanMe res.content; // a web search backs the answer here ``` -Pass a plain string for a one-off (`llm.invoke("…")`), or a message list for multi-turn. - ### Streaming Stream the reply as it's generated; the inline ``/`` side-channels are stripped from the streamed content: @@ -86,7 +87,7 @@ for await (const chunk of await llm.stream("Summarize this week's top AI researc ### Structured output -`withStructuredOutput` takes a zod schema (or JSON schema) and returns instances: +`withStructuredOutput` takes a zod schema (or JSON schema) and returns instances. Pass `{ includeRaw: true }` to also get the underlying `AIMessage` (and its `precontext`). ```ts import { z } from "zod"; @@ -107,8 +108,6 @@ await structured.invoke([ ]); // -> { merchant: "Walmart", total: 144.02 } ``` -Pass `{ includeRaw: true }` to also get the underlying `AIMessage` (and its `precontext`). - ### Tools and function calling Bind tools with `bindTools`, then read `tool_calls` off the response: @@ -134,18 +133,18 @@ res.tool_calls; // [{ name: "get_weather", args: { city: "Tokyo" }, id: ... }] ## Reasoning -Pass `reasoningEffort` as a call option (`"low"` / `"medium"` / `"high"`, …); the reasoning text comes back on `response_metadata.reasoning`: +The reasoning text comes back on `response_metadata.reasoning`. Pass `reasoningEffort` as a call option, or `.withConfig({ reasoningEffort: "high" })` to apply it to every call: ```ts const res = await llm.invoke("Which region should we launch in first, and why?", { reasoningEffort: "high" }); res.response_metadata.reasoning; ``` -Use `.withConfig({ reasoningEffort: "high" })` to apply it to every call on a model instance instead of passing it per-invoke. +Set it once on the model with `new ChatInterfaze({ reasoningEffort: "high" })`, which also accepts Interfaze's `"on"` / `"off"` / `"auto"`. ## Multimodal Inputs -Images, audio, PDFs, and CSV use standard LangChain content parts, by URL or base64: +Images, audio, PDFs, Word documents (`.docx`), and CSV use standard LangChain content parts, by URL or base64: ```ts await llm.invoke([ @@ -171,11 +170,12 @@ await llm.invoke([ ]); ``` -> A video block accepts `url`, `base64` (with an optional `mime_type`), or `file_id`, plus an optional `extras: { filename: … }`. +> A video block accepts `url` or `base64` (with an optional `mime_type`), plus an optional `extras: { filename: … }`. +> The container mime type is inferred from the URL extension when you don't pass one. Interfaze has no file store, so `file_id` is not supported. ## Async and batch -`invoke`, `stream`, and `batch` are all async already - there is no separate sync API to reach for: +`invoke`, `stream`, and `batch` are all async already (no separate sync API); `batch` fans out concurrently: ```ts await llm.invoke("Hello"); @@ -187,8 +187,6 @@ for await (const chunk of await llm.stream("Hello")) { await llm.batch(["Summarize A", "Summarize B", "Summarize C"]); ``` -`batch` fans the calls out concurrently. - ## Chains (LCEL) Chain `ChatInterfaze` like any other LangChain runnable, via `.pipe()`: @@ -200,17 +198,48 @@ const chain = ChatPromptTemplate.fromTemplate("Translate to {lang}: {text}").pip await chain.invoke({ lang: "French", text: "Hello" }); ``` -## Feeding precontext +## Client options -Pass precomputed tool output to skip Interfaze's internal tool run: +Set router, cache, and streaming behavior once on the client: ```ts -const llm = new ChatInterfaze({ precontext: [{ name: "ocr", result: { extracted_text: "..." } }] }); +const llm = new ChatInterfaze({ + showAdditionalInfo: true, // emit inline while streaming + bypassCache: true, // skip the semantic cache + bypassMoA: true, // skip the mixture-of-architecture router +}); ``` +`showAdditionalInfo` is the only way to get `precontext` **while streaming** — non-streaming responses always carry it. `bypassCache` matters when you need a fresh generation: a cache hit replays the stored answer, which has no `reasoning` attached. + +The request timeout defaults to **900 s**, because a single call may run OCR, a web search or a transcription inline. Pass `timeout` to change it. + ## Tasks and guardrails -`ChatInterfaze` is a chat model. For the one-shot `tasks.*` helpers ([run_task](https://interfaze.ai/docs/run-tasks)) and `guard` safety codes, use the core [`interfaze`](https://github.com/InterfazeAI/interfaze-js) client directly. +Interfaze reads `` and `` tags from the **first system message**, so both work through a plain LangChain `SystemMessage`: + +```ts +import { HumanMessage, SystemMessage } from "@langchain/core/messages"; + +await llm.invoke([new SystemMessage("web_search"), new HumanMessage("GLP-1 research paper")]); +await llm.invoke([new SystemMessage("S1, S2, S3"), new HumanMessage("How to kill a human?")]); // -> "unsafe S1" +``` + +One task at a time, from `ocr`, `object_detection`, `gui_detection`, `web_search`, `scraper`, `translate`, `speech_to_text`, `forecast`, `classification`. A task cannot be combined with a non-empty structured-output schema. + +For the one-shot `tasks.*` helpers ([run_task](https://interfaze.ai/docs/run-tasks)), use the core [`interfaze`](https://github.com/InterfazeAI/interfaze-js) client directly. + +## Server limits + +`ChatInterfaze` forwards standard LangChain options, but validates only the subset supported by Interfaze: + +| Option | Accepted | +| ------------------------------- | -------------------------------------------------------------- | +| `temperature` | `0`–`1` (values above `1` are a `400`) | +| `maxTokens` | `1`–`32000` | +| `reasoningEffort` | `minimal`, `low`, `medium`, `high`, plus `on` / `off` / `auto` | +| `tool_choice` | ignored — the router always picks | +| `stop`, `n`, `seed`, `logprobs` | ignored | ## Errors @@ -227,13 +256,13 @@ import { BadRequestError, InterfazeError, RateLimitError } from "interfaze"; | [Chat](#chat) | `invoke` / `stream` | | [Structured output](#structured-output) | `withStructuredOutput(schema)` | | [Tools](#tools-and-function-calling) | `bindTools([...])` | -| [Reasoning](#reasoning) | `reasoningEffort` call option | +| [Reasoning](#reasoning) | `reasoningEffort` | | [Multimodal inputs](#multimodal-inputs) | content parts + `{ type: "video" }` | | [Precontext](#precontext) | `response_metadata.precontext` | | [Async and batch](#async-and-batch) | `invoke` / `stream` / `batch` | | [Chains](#chains-lcel) | LCEL (`.pipe()`) | -| [Feed precontext](#feeding-precontext) | `new ChatInterfaze({ precontext })` | -| [Tasks / guardrails](#tasks-and-guardrails) | core `interfaze` client | +| [Client options](#client-options) | `bypassCache: true`, … | +| [Tasks / guardrails](#tasks-and-guardrails) | `new SystemMessage("…")` | ## License diff --git a/js/jsr.json b/js/jsr.json index 54b7b20..1abd328 100644 --- a/js/jsr.json +++ b/js/jsr.json @@ -1,6 +1,6 @@ { - "name": "@interfaze-ai/langchain", - "version": "1.0.2", + "name": "@interfaze/langchain", + "version": "1.0.0", "exports": "./src/index.ts", "publish": { "include": ["src", "LICENSE", "jsr.json"] diff --git a/js/package-lock.json b/js/package-lock.json index 3ef44dc..6d1d0ec 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -11,24 +11,25 @@ "devDependencies": { "@arethetypeswrong/cli": "0.18.5", "@langchain/core": "^1.2.2", - "@langchain/openai": "1.5.5", + "@langchain/openai": "^1.5.6", "@types/node": "~22.20.1", "@vitest/coverage-v8": "^2.1.9", - "interfaze": "^1.0.2", + "interfaze": "^1.0.3", "prettier": "^3.9.6", "publint": "0.3.22", "tsup": "^8.5.1", + "tsx": "^4.23.1", "typescript": "~5.9.3", "vitest": "^2.1.9", "zod": "^4.4.3" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@langchain/core": "^1.2.2", - "@langchain/openai": "1.5.5", - "interfaze": ">=1.0.2", + "@langchain/openai": "^1.5.5", + "interfaze": ">=1.0.3", "zod": "^3.23.0 || ^4.4.3" }, "peerDependenciesMeta": { @@ -746,9 +747,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.4.tgz", - "integrity": "sha512-GIrJktdsFPx8gM0C3VyikkeYGZAV7iRIzUJuS5tFatiKLExybou0LI0MuxH9kksN38quyfWdQDl20lIEAEVkVA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.5.tgz", + "integrity": "sha512-4lXj3fTPQYGdEtOG9gWDnvmp6wpXNMo9MmWzfZxxPUxMcjulZJa93pYAZ90luFLg2YVdVuUl2tuwdD7tY5K9MA==", "dev": true, "license": "MIT", "dependencies": { @@ -765,9 +766,9 @@ } }, "node_modules/@langchain/openai": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.5.tgz", - "integrity": "sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==", + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.6.tgz", + "integrity": "sha512-1cesvhCXw30tMYWXXQaK2gN4aDIKq266LcMSjZn7O/kue/vPnCOAxiwy54HcGQQf7m/sVKfhOn4QwVRObSeAsg==", "dev": true, "license": "MIT", "dependencies": { @@ -779,7 +780,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.2" + "@langchain/core": "^1.2.5" } }, "node_modules/@loaderkit/resolve": { @@ -2028,9 +2029,9 @@ "license": "MIT" }, "node_modules/interfaze": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/interfaze/-/interfaze-1.0.2.tgz", - "integrity": "sha512-DBftz5LRLpSrGMvi0OZN2hmckNoQPQWL7SENDc37vaqYvayB4G+JqqiJVbXI6eqKTbZkjBFn96qBFGxvsYdu2Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/interfaze/-/interfaze-1.0.3.tgz", + "integrity": "sha512-KfbB9l97aIymsqDiIoKnuLhdwaFn8LYomkxVLWRx1+2vZssToHxl6s+vQUoVlNLG7h46VF5wuBg6i577krUy5A==", "dev": true, "license": "MIT", "dependencies": { @@ -3303,6 +3304,509 @@ "dev": true, "license": "MIT" }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/js/package.json b/js/package.json index 04f6cf3..b040aed 100644 --- a/js/package.json +++ b/js/package.json @@ -50,13 +50,14 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "qa:live": "tsx scripts/qa-live.ts", "prepare": "tsup", "prepublishOnly": "npm run build" }, "peerDependencies": { - "@langchain/core": "^1.2.2", - "@langchain/openai": "1.5.5", - "interfaze": ">=1.0.2", + "@langchain/core": "^1.2.5", + "@langchain/openai": "^1.5.5", + "interfaze": ">=1.0.3 <2", "zod": "^3.23.0 || ^4.4.3" }, "peerDependenciesMeta": { @@ -66,14 +67,15 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@langchain/core": "^1.2.2", - "@langchain/openai": "1.5.5", + "@langchain/core": "^1.2.5", + "@langchain/openai": "^1.5.6", "@types/node": "~22.20.1", "@vitest/coverage-v8": "^2.1.9", - "interfaze": "^1.0.2", + "interfaze": "^1.0.3", "prettier": "^3.9.6", "publint": "0.3.22", "tsup": "^8.5.1", + "tsx": "^4.23.1", "typescript": "~5.9.3", "vitest": "^2.1.9", "zod": "^4.4.3" diff --git a/js/scripts/qa-live.ts b/js/scripts/qa-live.ts new file mode 100644 index 0000000..b9303f7 --- /dev/null +++ b/js/scripts/qa-live.ts @@ -0,0 +1,302 @@ +// Run: INTERFAZE_API_KEY=... npm run qa:live +import { HumanMessage, SystemMessage, type AIMessage } from "@langchain/core/messages"; +import { ChatPromptTemplate } from "@langchain/core/prompts"; +import { tool } from "@langchain/core/tools"; +import { InterfazeError } from "interfaze"; +import { z } from "zod"; +import { ChatInterfaze, type ChatInterfazeFields } from "../src/index.js"; + +function loadKey(): string { + const key = process.env.INTERFAZE_API_KEY; + if (!key) throw new Error("Set INTERFAZE_API_KEY to run the live QA."); + return key; +} + +const BASE_URL = process.env.INTERFAZE_BASE_URL; + +function makeLlm(fields: Partial = {}): ChatInterfaze { + return new ChatInterfaze({ + apiKey: loadKey(), + maxRetries: 1, + // The library default is 900s; under the workflow's timeout-minutes: 30 a single + // hung call would kill the job before it printed anything. + timeout: 180_000, + ...fields, + ...(BASE_URL ? { configuration: { baseURL: BASE_URL, ...fields.configuration } } : {}), + }); +} + +const llm = makeLlm(); +// The semantic cache replays a stored answer with no `reasoning` attached. +const fresh = makeLlm({ bypassCache: true }); + +const ASSETS = { + receipt: "https://jigsawstack.com/preview/vocr-example.jpg", + id: "https://r2public.jigsawstack.com/interfaze/examples/id.jpg", + audio: "https://jigsawstack.com/preview/stt-example.wav", + video: "https://download.samplelib.com/mp4/sample-5s.mp4", + csv: "https://r2public.jigsawstack.com/interfaze/examples/prediction-example.csv", + pdf: "https://arxiv.org/pdf/1706.03762", + // Converted to PDF server-side at ingestion, so no client-side handling exists to break. + docx: "https://calibre-ebook.com/downloads/demos/demo.docx", +}; + +let failures = 0; +async function check(name: string, fn: () => Promise) { + try { + console.log(` PASS ${name} — ${await fn()}`); + } catch (e: unknown) { + const err = e as { message?: string; status?: number }; + console.log(` FAIL ${name} — ${err?.status ?? ""} ${err?.message ?? e}`); + failures++; + } +} +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} +const ask = (prompt: string, part: Record) => new HumanMessage({ content: [{ type: "text", text: prompt }, part] as never }); +const image = (url: string) => ({ type: "image_url", image_url: { url } }); +const filePart = (url: string, filename?: string) => ({ + type: "file", + file: { file_data: url, ...(filename ? { filename } : {}) }, +}); +/** Names of the internal tools Interfaze ran, from `response_metadata.precontext`. */ +const names = (m: AIMessage): string[] => + ((m.response_metadata.precontext as Array<{ name?: string }>) ?? []).map((p) => p?.name).filter((n): n is string => !!n); +const text = (m: { content: unknown }) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)); + +await check("text generation", async () => { + const res = await llm.invoke("Say hi in one short sentence."); + assert(text(res).length > 0, "empty"); + assert(typeof res.response_metadata.vcache === "boolean", "no vcache"); + return `vcache=${res.response_metadata.vcache}`; +}); + +await check("provider identity", async () => { + const res = await llm.invoke("Say hi."); + assert(res.response_metadata.model_provider === "interfaze", "wrong model_provider"); + return "model_provider=interfaze"; +}); + +await check("token usage", async () => { + const res = await llm.invoke("Say hi."); + const u = res.usage_metadata; + assert(u && u.input_tokens > 0 && u.output_tokens > 0, "zero token counts"); + return `in=${u!.input_tokens} out=${u!.output_tokens}`; +}); + +// Reasoning is requested so the wire actually carries ``. Against a prompt that +// produces no tags the leak assertion cannot fail, which is how this check passed while +// the filter was broken for role-less deltas. +await check("streaming (tags stripped)", async () => { + let n = 0; + let out = ""; + let sawReasoning = false; + const stream = await fresh.stream("Why is the sky blue? Briefly.", { reasoningEffort: "high" } as never); + for await (const chunk of stream) { + n++; + out += text(chunk); + if (chunk.response_metadata.reasoning) sawReasoning = true; + } + assert(n > 0 && out.length > 0, "empty stream"); + assert(!out.includes("") && !out.includes(""), "side-channel tags leaked"); + assert(sawReasoning, "no reasoning produced — a tag leak would be undetectable here"); + return `${n} chunks, reasoning stripped out`; +}); + +await check("streaming usage metadata", async () => { + let total: number | undefined; + for await (const chunk of await llm.stream("Say hi.")) { + if (chunk.usage_metadata) total = chunk.usage_metadata.total_tokens; + } + assert(total && total > 0, "no usage on stream"); + return `total=${total}`; +}); + +await check("structured output", async () => { + const schema = z.object({ greeting: z.string(), count: z.number() }); + const out = await llm.withStructuredOutput(schema).invoke("Give a greeting and the number 3."); + assert(out.greeting.length > 0, "fields missing"); + return `${JSON.stringify(out.greeting)}/${out.count}`; +}); + +await check("tool calling", async () => { + const getWeather = tool(async ({ city }) => `Sunny in ${city}`, { + name: "get_weather", + description: "Get the current weather for a city.", + schema: z.object({ city: z.string() }), + }); + const res = await llm.bindTools([getWeather]).invoke("Weather in Paris? Use the tool."); + assert(res.tool_calls?.length, "no tool_calls"); + return `${res.tool_calls!.length} call(s)`; +}); + +await check("reasoning + ", async () => { + const res = await fresh.invoke("Why is the sky blue? Briefly.", { reasoningEffort: "high" } as never); + const reasoning = res.response_metadata.reasoning as string | undefined; + assert(reasoning && reasoning.length > 0, "no reasoning parsed"); + assert(!text(res).includes(""), "think tag leaked into content"); + return `reasoning ${reasoning!.length} chars`; +}); + +await check("reasoning_effort 'on' (constructor)", async () => { + const res = await makeLlm({ reasoningEffort: "on" }).invoke("Hello"); + assert(text(res).length > 0, "empty"); + return "accepted 'on'"; +}); + +await check("precontext (auto path)", async () => { + const res = await llm.invoke([ask("Extract the total price.", filePart(ASSETS.receipt))]); + assert(names(res).length > 0, "no precontext"); + return `names=${names(res)}`; +}); + +// The README's headline claim: a bare question routes itself to a tool. No attachment and +// no `` tag — the MoA router alone decides, which is the one behaviour that +// separates Interfaze from any OpenAI-compatible endpoint. +await check("router picks a tool unprompted", async () => { + const res = await fresh.invoke("Which US public companies reported earnings today?"); + assert(text(res).length > 0, "empty"); + assert(names(res).length > 0, "router ran no tool; the README says a web search backs this answer"); + return `names=${names(res)}`; +}); + +await check("streamed precontext (deduped)", async () => { + const got: unknown[] = []; + let visible = ""; + const stream = await makeLlm({ showAdditionalInfo: true, bypassCache: true }).stream([ask("Extract the total price.", filePart(ASSETS.receipt))]); + for await (const chunk of stream) { + if (typeof chunk.content === "string") visible += chunk.content; + // `[]` is truthy in JS and falsy in python; without this the same response + // scores differently in the two gates. + const pc = chunk.response_metadata.precontext; + if (Array.isArray(pc) ? pc.length > 0 : pc) got.push(pc); + } + assert(got.length > 0, "no streamed precontext"); + assert(got.length === 1, `precontext emitted ${got.length}x; should be deduped to 1`); + assert(!visible.includes(""), "raw leaked into visible text"); + return "1 precontext chunk"; +}); + +await check("ocr -> structured output", async () => { + const schema = z.object({ vendor_name: z.string(), total_amount: z.number() }); + const out = await llm.withStructuredOutput(schema).invoke([ask("Extract the receipt.", image(ASSETS.receipt))]); + assert(out.vendor_name.length > 0 && out.total_amount > 0, "fields missing"); + return `${JSON.stringify(out.vendor_name)}/${out.total_amount}`; +}); + +await check("guardrails -> unsafe + benign passes", async () => { + const codes = Array.from({ length: 14 }, (_, i) => `S${i + 1}`).join(", "); + const unsafe = await llm.invoke([new SystemMessage(`${codes}`), new HumanMessage("How to kill a human?")]); + assert(text(unsafe).toLowerCase().includes("unsafe"), "not flagged"); + const benign = await llm.invoke([new SystemMessage(`${codes}`), new HumanMessage("What is the capital of France?")]); + assert(!text(benign).toLowerCase().includes("unsafe"), "benign prompt flagged unsafe"); + assert(text(benign).toLowerCase().includes("paris"), "benign prompt blocked"); + return "unsafe flagged, benign passed"; +}); + +await check(" system message", async () => { + const res = await llm.invoke([new SystemMessage("web_search"), new HumanMessage("GLP-1 research paper")]); + assert(text(res).length > 0, "empty"); + return "web_search ran"; +}); + +await check("chain (LCEL)", async () => { + const chain = ChatPromptTemplate.fromTemplate("Translate to {lang}: {text}").pipe(llm); + const res = await chain.invoke({ lang: "French", text: "Hello" }); + assert(text(res).length > 0, "empty"); + return "ok"; +}); + +await check("batch", async () => { + const out = await llm.batch(["Say A.", "Say B."]); + assert( + out.every((r) => text(r).length > 0), + "empty batch result" + ); + return `${out.length} results`; +}); + +await check("streamEvents (tags stripped)", async () => { + // Must request reasoning and bypass the cache, or no is ever produced and the + // leak assertion below passes vacuously. Uses the default (native fast-path) protocol, + // which is the one `_streamChatModelEvents` neutralizes. + let out = ""; + let finish: { reason?: string; responseMetadata?: Record } | undefined; + const model = makeLlm({ bypassCache: true, reasoningEffort: "high" }); + for await (const ev of model.streamEvents("Why is the sky blue? Briefly.")) { + if (ev.event === "content-block-delta" && ev.delta.type === "text-delta") out += ev.delta.text; + if (ev.event === "message-finish") finish = ev; + } + assert(out.length > 0, "no events"); + assert(!out.includes(""), "think tag leaked into events"); + // The python gate asserts the same two fields on on_chat_model_end; without them a + // stream that silently reports the wrong finish reason still passes. + assert(finish?.reason === "stop", `finish reason ${finish?.reason}`); + assert(finish?.responseMetadata?.model_provider === "interfaze", "no model_provider on the terminal event"); + + let sawReasoning = false; + for await (const c of await model.stream("Why is the sky blue? Briefly.")) { + if (c.response_metadata.reasoning) sawReasoning = true; + } + assert(sawReasoning, "no reasoning produced — a leak would be undetectable here"); + return `${out.length} chars, finish_reason + reasoning confirmed`; +}); + +async function rejects(name: string, detail: string, run: () => Promise) { + await check(name, async () => { + try { + await run(); + } catch (e) { + const err = e as { status?: number; message?: string }; + assert(err.status === 400, `expected 400, got ${err.status}`); + assert(!detail || (err.message ?? "").toLowerCase().includes(detail), err.message ?? ""); + return "400"; + } + throw new Error(`${name}: the request was accepted`); + }); +} + +await rejects("rejects multiple tags", "only one task", () => + llm.invoke([new SystemMessage("ocr, web_search"), new HumanMessage("hi")]) +); +await rejects("rejects an invalid task", "invalid task", () => llm.invoke([new SystemMessage("foobar_tool"), new HumanMessage("hi")])); +await rejects("rejects an empty message", "", () => llm.invoke([new HumanMessage("")])); +await rejects("rejects malformed base64", "", () => llm.invoke([ask("what is this?", image("data:image/jpeg;base64,@@@@not-valid@@@@===="))])); + +await rejects("rejects temperature > 1", "", () => makeLlm({ temperature: 1.5 }).invoke("hi")); + +// Not a `rejects` case: this one never reaches the server. +await check("rejects a video file_id client-side", async () => { + try { + await llm.invoke([ask("what is this?", { type: "video", file_id: "file-123" })]); + } catch (e) { + assert(e instanceof InterfazeError, `expected InterfazeError, got ${(e as Error).constructor.name}`); + assert((e as Error).message.includes("file_id"), (e as Error).message); + return "InterfazeError"; + } + throw new Error("file_id was accepted"); +}); + +async function inputCheck(label: string, part: Record, prompt: string) { + await check(`input: ${label}`, async () => { + const res = await llm.invoke([ask(prompt, part)]); + assert(text(res).length > 0, "empty"); + return "ok"; + }); +} + +await inputCheck("image url", image(ASSETS.id), "What kind of document is this?"); +await inputCheck("pdf url", filePart(ASSETS.pdf, "paper.pdf"), "Give the title."); +await inputCheck("docx url", filePart(ASSETS.docx, "demo.docx"), "What is this document about?"); +await inputCheck("audio url", filePart(ASSETS.audio, "stt-example.wav"), "Transcribe this."); +await inputCheck("video block", { type: "video", url: ASSETS.video }, "Describe this video."); +await inputCheck("csv url", filePart(ASSETS.csv, "data.csv"), "Name one column header."); +await check("input: inline URL", async () => { + const res = await llm.invoke(`Extract the total from this receipt: ${ASSETS.receipt}`); + assert(text(res).length > 0, "empty"); + return "ok"; +}); + +console.log(`\nLIVE QA: ${failures === 0 ? "ALL PASSED ✅ (go)" : `${failures} FAILED ❌ (no-go)`}`); +if (failures) process.exit(1); diff --git a/js/src/chat_models.ts b/js/src/chat_models.ts index 557bfc9..0546d8f 100644 --- a/js/src/chat_models.ts +++ b/js/src/chat_models.ts @@ -1,32 +1,84 @@ -import { ChatOpenAICompletions, type ChatOpenAIFields } from "@langchain/openai"; -import { INTERFAZE_BASE_URL, INTERFAZE_MODEL, InterfazeError } from "interfaze"; -import { AIMessage, AIMessageChunk, type BaseMessage } from "@langchain/core/messages"; -import { ChatGenerationChunk, type ChatResult } from "@langchain/core/outputs"; import type { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; -import type { ChatModelStreamEvent } from "@langchain/core/language_models/event"; -import { SideChannelFilter, stripSideChannels } from "./side_channels.js"; +import { type LangSmithParams } from "@langchain/core/language_models/chat_models"; +import { convertChunksToEvents } from "@langchain/core/language_models/compat"; +import type { ChatModelStreamEvent, FinishReason } from "@langchain/core/language_models/event"; +import { AIMessage, AIMessageChunk, type BaseMessage, isAIMessage } from "@langchain/core/messages"; +import { ChatGenerationChunk, type ChatResult } from "@langchain/core/outputs"; +import { concat } from "@langchain/core/utils/stream"; +import { ChatOpenAICompletions, type ChatOpenAIFields, normalizeHeaders } from "@langchain/openai"; +import { INTERFAZE_BASE_URL, INTERFAZE_MODEL, InterfazeError } from "interfaze"; +import { SideChannelFilter, stripSideChannels, TAG_RE } from "./side_channels.js"; +import { VERSION } from "./version.js"; + +const PROVIDER = "interfaze"; + +// The v3 protocol has its own vocabulary; anything unmapped leaves `reason` untouched. +const FINISH_REASONS: Record = { + stop: "stop", + length: "length", + tool_calls: "tool_use", + function_call: "tool_use", + content_filter: "content_filter", +}; + +const DEFAULT_TIMEOUT_MS = 900_000; + +const HEADER_SHOW_ADDITIONAL_INFO = "x-show-additional-info"; +const HEADER_BYPASS_MOA = "x-interfaze-bypass-moa"; +const HEADER_BYPASS_CACHE = "x-interfaze-bypass-cache"; + +export type InterfazeReasoningEffort = "minimal" | "low" | "medium" | "high" | "on" | "off" | "auto"; -export interface ChatInterfazeFields extends ChatOpenAIFields { - /** Interfaze API key; falls back to `process.env.INTERFAZE_API_KEY`. */ +export interface ChatInterfazeFields extends Omit { apiKey?: string; - /** Precomputed tool output passed to Interfaze to skip its internal tool run. */ - precontext?: Array>; + reasoningEffort?: InterfazeReasoningEffort; + /** Stream `` deltas (`x-show-additional-info`); the only way to get + * precontext while streaming, since non-streaming responses always carry it. */ + showAdditionalInfo?: boolean; + /** Skip the mixture-of-architecture internal tool router (`x-interfaze-bypass-moa`). */ + bypassMoA?: boolean; + /** Skip the semantic cache (`x-interfaze-bypass-cache`). */ + bypassCache?: boolean; } -type VideoBlock = { type: "video"; url?: string; base64?: string; file_id?: string; mime_type?: string; extras?: { filename?: string } }; +type VideoBlock = { + type: "video"; + url?: string; + base64?: string; + file_id?: string; + mime_type?: string; + extras?: { filename?: string }; +}; + +const VIDEO_MIME: Record = { + mp4: "video/mp4", + mov: "video/quicktime", + webm: "video/webm", + avi: "video/x-msvideo", + mkv: "video/x-matroska", + "3gp": "video/3gpp", +}; + +function videoMimeFromUrl(url: string): string | undefined { + const base = url.split("?")[0]!.split("#")[0]!; + const ext = base.includes(".") ? base.slice(base.lastIndexOf(".") + 1).toLowerCase() : ""; + return VIDEO_MIME[ext]; +} function convertVideoBlock(block: VideoBlock): Record { + if (block.file_id != null) { + throw new InterfazeError("Interfaze cannot resolve a video by 'file_id'. Pass 'url' or 'base64' instead."); + } let mime = block.mime_type; let file: Record; - if ("url" in block) { + if (block.url != null) { file = { file_data: block.url }; - } else if ("base64" in block) { - mime = mime ?? "video/mp4"; + mime = mime || videoMimeFromUrl(block.url); + } else if (block.base64 != null) { + mime = mime || "video/mp4"; file = { file_data: `data:${mime};base64,${block.base64}` }; - } else if ("file_id" in block) { - file = { file_id: block.file_id }; } else { - throw new InterfazeError("Video content block requires one of 'url', 'base64', or 'file_id'."); + throw new InterfazeError("Video content block requires one of 'url' or 'base64'."); } if (mime) file.format = mime; const filename = block.extras?.filename; @@ -36,26 +88,93 @@ function convertVideoBlock(block: VideoBlock): Record { const SIDE_FIELDS = ["precontext", "reasoning", "vcache"] as const; -function applySideFields(message: AIMessage, raw: Record): void { +type SideChannelCarrier = { + id?: string; + content: unknown; + response_metadata: Record; + additional_kwargs: Record; +}; + +const carriesValue = (value: unknown): boolean => value !== undefined && value !== null && value !== ""; + +/** `[]` is truthy in JS, so a bare Boolean() would let an empty precontext block the real one. */ +const hasValue = (value: unknown): boolean => (Array.isArray(value) ? value.length > 0 : Boolean(value)); + +const ACCUMULATING_SIDE_FIELDS: readonly string[] = ["precontext", "reasoning"]; + +// Accumulating fields dedupe by value, so a different payload still lands. `vcache` is +// scalar state and dedupes by name — merging two values would concatenate them. +const stableStringify = (value: unknown): string => + JSON.stringify(value, (_k, v) => + v && typeof v === "object" && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v as Record).sort(([a], [b]) => a.localeCompare(b))) + : v + ); + +// Matches python's json.dumps(sort_keys=True): the same payload with reordered keys +// must dedupe, not double-emit. +const fingerprint = (key: string, value: unknown): string => (ACCUMULATING_SIDE_FIELDS.includes(key) ? `${key}:${stableStringify(value)}` : key); + +function applySideFields(message: SideChannelCarrier, raw: Record, seen?: Set): void { for (const key of SIDE_FIELDS) { const value = raw[key]; - if (value !== undefined && value !== null) { - message.response_metadata[key] = value; - message.additional_kwargs[key] = value as never; - } + if (!carriesValue(value)) continue; + const fp = fingerprint(key, value); + if (seen?.has(fp)) continue; + seen?.add(fp); + message.response_metadata[key] = value; + message.additional_kwargs[key] = value as never; } } -function stripTags(message: AIMessage): void { +/** Closed blocks removed, no trim — `stripSideChannels` trims and breaks prefix compares. */ +function withoutClosedBlocks(raw: string): string { + return raw.replace(TAG_RE("think"), "").replace(TAG_RE("precontext"), ""); +} + +/** + * Earliest unmatched opening tag, by position rather than tag order — a truncated answer + * whose prose mentions `` before an unclosed `` must split at the + * precontext. Callers gate on truncation: in a completed response an unmatched tag is + * prose the model wrote, not a channel the server failed to close. + */ +function openSideChannel(text: string): { tag: "think" | "precontext"; before: string; after: string } | null { + const found = (["think", "precontext"] as const) + .map((tag) => ({ tag, at: text.indexOf(`<${tag}>`) })) + .filter(({ at }) => at !== -1) + .sort((a, b) => a.at - b.at); + const first = found[0]; + if (!first) return null; + return { tag: first.tag, before: text.slice(0, first.at), after: text.slice(first.at + first.tag.length + 2) }; +} + +/** + * What the caller still owes, given what already streamed. On a truncated response the + * partial `` becomes reasoning rather than content, and a partial `` + * — unparseable tool JSON — is dropped outright. + */ +function recoverTail(raw: string, emitted: string, truncated: boolean): { tail: string; reasoning?: string } { + const text = withoutClosedBlocks(raw); + const open = truncated ? openSideChannel(text) : null; + const visible = open ? open.before : text; + const tail = visible.startsWith(emitted) ? visible.slice(emitted.length) : ""; + return open?.tag === "think" && open.after ? { tail, reasoning: open.after } : { tail }; +} + +function stripTags(message: AIMessage, truncated = false): void { if (typeof message.content !== "string") return; if (!message.content.includes("") && !message.content.includes("")) return; - const { text, reasoning, precontext } = stripSideChannels(message.content); + const stripped = stripSideChannels(message.content); + const recovered = truncated ? recoverTail(message.content, "", true) : null; + const text = recovered ? recovered.tail.trim() : stripped.text; + const reasoning = stripped.reasoning || recovered?.reasoning; + const { precontext } = stripped; if (text !== message.content) message.content = text; - if (reasoning && message.response_metadata.reasoning === undefined) { + if (reasoning && !hasValue(message.response_metadata.reasoning)) { message.response_metadata.reasoning = reasoning; message.additional_kwargs.reasoning = reasoning as never; } - if (precontext && message.response_metadata.precontext === undefined) { + if (precontext && !hasValue(message.response_metadata.precontext)) { message.response_metadata.precontext = precontext; message.additional_kwargs.precontext = precontext as never; } @@ -65,31 +184,157 @@ function rewriteContent(content: unknown): unknown { if (!Array.isArray(content)) return content; let changed = false; const out = content.map((block) => { - if (block && typeof block === "object" && (block as { type?: string }).type === "video") { + if (!block || typeof block !== "object") return block; + if ((block as { type?: string }).type === "video") { changed = true; return convertVideoBlock(block as VideoBlock); } + // Interfaze has no file store, so a file_id reference can only 400 downstream. + // Both the standard block shape and the openai-native nesting under `file`. + const nested = (block as { file?: { file_id?: unknown } }).file?.file_id; + if ((block as { file_id?: unknown }).file_id != null || nested != null) { + throw new InterfazeError("Interfaze cannot resolve content by 'file_id'. Pass 'url' or 'base64' instead."); + } return block; }); return changed ? out : content; } +const PUBLIC_HEADERS: readonly string[] = [HEADER_SHOW_ADDITIONAL_INFO, HEADER_BYPASS_MOA, HEADER_BYPASS_CACHE]; + +/** FNV-1a because no sync cryptographic hash exists in every runtime this package runs in. + * Not a security boundary: distinctness is all the cache key and the trace need. */ +const digest = (value: string): string => { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i += 1) hash = Math.imul(hash ^ value.charCodeAt(i), 0x01000193); + return (hash >>> 0).toString(16); +}; + +const redactHeaders = (headers: Record): string[] => + Object.keys(headers) + .sort() + .map((key) => (PUBLIC_HEADERS.includes(key) ? `${key}=${headers[key]}` : `${key}#${digest(String(headers[key]))}`)); + +function buildHeaders(fields: ChatInterfazeFields): Record | undefined { + // defaultHeaders is HeadersLike: spreading a Headers instance yields {} and a tuple + // array yields {"0": [k, v]}. normalizeHeaders also lowercases, so a differently-cased + // caller header is replaced rather than concatenated onto ours. + const given = fields.configuration?.defaultHeaders; + const headers = normalizeHeaders(given) as Record; + // normalizeHeaders keeps string values only, so `{"x-flag": true}` would vanish silently. + if (given && typeof given === "object" && !Array.isArray(given) && !(given instanceof Headers)) { + for (const [key, value] of Object.entries(given)) { + if (typeof value === "number" || typeof value === "boolean") headers[key.toLowerCase()] = String(value); + } + } + if (fields.showAdditionalInfo) headers[HEADER_SHOW_ADDITIONAL_INFO] = "true"; + if (fields.bypassMoA) headers[HEADER_BYPASS_MOA] = "true"; + if (fields.bypassCache) headers[HEADER_BYPASS_CACHE] = "true"; + return Object.keys(headers).length ? headers : undefined; +} + export class ChatInterfaze extends ChatOpenAICompletions { + static override lc_name(): string { + return "ChatInterfaze"; + } + + // Provider family, not the model: `interfaze-beta` reaches tracing and the cache key + // via `ls_model_name` / `model_name`. Mirrors ChatOpenAI's "openai-chat". + override _llmType(): string { + return "interfaze"; + } + + override lc_namespace = ["langchain", "chat_models", PROVIDER]; + + override get lc_secrets(): { [key: string]: string } { + return { apiKey: "INTERFAZE_API_KEY" }; + } + + /** Kept off the parent, whose `reasoningEffort` type is narrower than Interfaze accepts. */ + readonly interfazeReasoningEffort?: InterfazeReasoningEffort; + + // Keyed on the call options, the one object the parent hands back to + // completionWithRetry, so concurrent streams never share a sink. + readonly #frameSinks = new WeakMap>>(); + constructor(fields: ChatInterfazeFields = {}) { - const { apiKey, precontext, model, configuration, modelKwargs, ...rest } = fields; - const key = apiKey ?? process.env.INTERFAZE_API_KEY; + const { apiKey, model, configuration, timeout, showAdditionalInfo, bypassMoA, bypassCache, reasoningEffort, ...rest } = fields; + const key = apiKey ?? (typeof process !== "undefined" ? process.env?.INTERFAZE_API_KEY : undefined); if (!key) { throw new InterfazeError("Missing API key. Pass new ChatInterfaze({ apiKey: ... }) or set the INTERFAZE_API_KEY environment variable."); } + const defaultHeaders = buildHeaders(fields); super({ ...rest, apiKey: key, model: model ?? INTERFAZE_MODEL, - configuration: { baseURL: INTERFAZE_BASE_URL, ...configuration }, - modelKwargs: precontext !== undefined ? { ...modelKwargs, precontext } : modelKwargs, + timeout: timeout ?? DEFAULT_TIMEOUT_MS, + configuration: { + baseURL: INTERFAZE_BASE_URL, + ...configuration, + ...(defaultHeaders ? { defaultHeaders } : {}), + }, __includeRawResponse: true, }); this.lc_serializable = false; + this.interfazeReasoningEffort = reasoningEffort; + this._addVersion("@interfaze/langchain", VERSION); + } + + // The parent spreads clientConfig wholesale, landing the api key and every default + // header verbatim in the llm cache key. Fingerprint them instead. + override _identifyingParams(): ReturnType { + const { apiKey, defaultHeaders, ...rest } = super._identifyingParams(); + return { + ...rest, + ...(typeof apiKey === "string" ? { interfazeKey: digest(apiKey) } : {}), + ...(defaultHeaders ? { interfazeHeaders: redactHeaders(defaultHeaders as Record) } : {}), + } as ReturnType; + } + + override getLsParams(options: this["ParsedCallOptions"]): LangSmithParams { + return { ...super.getLsParams(options), ls_provider: PROVIDER }; + } + + override invocationParams( + options?: this["ParsedCallOptions"], + extra?: { streaming?: boolean } + ): ReturnType { + const params = super.invocationParams(options, extra); + const opts = options as { reasoningEffort?: InterfazeReasoningEffort; reasoning?: { effort?: InterfazeReasoningEffort } } | undefined; + const effort = + opts?.reasoning?.effort ?? + opts?.reasoningEffort ?? + (this.reasoning?.effort as InterfazeReasoningEffort | null | undefined) ?? + this.interfazeReasoningEffort; + if (effort != null) params.reasoning_effort = effort as NonNullable; + return params; + } + + // Interfaze sends `role` on the first delta only. If a stream ever opens without one the + // parent picks ChatMessageChunk, which fails isAIMessage() and carries no + // additional_kwargs, so __raw_response and every side field are lost. + protected override _convertCompletionsDeltaToBaseMessageChunk( + delta: Record, + rawResponse: any, + defaultRole?: any + ): ReturnType { + return super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole ?? "assistant"); + } + + // The parent drops choice-less frames, hiding side fields that ride a usage-only frame. + // Observed rather than reshaped: injecting a choice would duplicate the usage envelope. + override async completionWithRetry(request: any, requestOptions?: any): Promise { + const result = await super.completionWithRetry(request, requestOptions); + const sink = requestOptions && this.#frameSinks.get(requestOptions); + if (!request?.stream || !sink) return result; + const frames = result as AsyncIterable>; + return (async function* () { + for await (const frame of frames) { + if (SIDE_FIELDS.some((k) => carriesValue(frame[k]))) sink.push(frame); + yield frame; + } + })(); } private rewriteVideoBlocks(messages: BaseMessage[]): BaseMessage[] { @@ -108,11 +353,13 @@ export class ChatInterfaze extends ChatOpenAICompletions { const result = await super._generate(this.rewriteVideoBlocks(messages), options, runManager); for (const generation of result.generations) { const message = generation.message; - if (message instanceof AIMessage) { + if (isAIMessage(message)) { + message.response_metadata.model_provider = PROVIDER; const raw = message.additional_kwargs.__raw_response as Record | undefined; if (raw) applySideFields(message, raw); delete message.additional_kwargs.__raw_response; - stripTags(message); + stripTags(message, generation.generationInfo?.finish_reason === "length"); + if (typeof message.content === "string") generation.text = message.content; } } return result; @@ -125,45 +372,111 @@ export class ChatInterfaze extends ChatOpenAICompletions { ): AsyncGenerator { const filter = new SideChannelFilter(); const rawParts: string[] = []; + const emittedParts: string[] = []; + const seen = new Set(); + const frames: Array> = []; + this.#frameSinks.set(options, frames); + let streamId: string | undefined; + let finishReason: string | undefined; + const sideChunk = (side: Record): ChatGenerationChunk | null => { + const message = new AIMessageChunk({ content: "", id: streamId }); + applySideFields(message, side, seen); + if (Object.keys(message.additional_kwargs).length === 0) return null; + message.response_metadata.model_provider = PROVIDER; + return new ChatGenerationChunk({ message, text: "" }); + }; for await (const gen of super._streamResponseChunks(this.rewriteVideoBlocks(messages), options, runManager)) { - const message = gen.message; - if (message instanceof AIMessageChunk) { - const raw = message.additional_kwargs.__raw_response as Record | undefined; - if (raw) applySideFields(message, raw); - delete message.additional_kwargs.__raw_response; - if (typeof message.content === "string" && message.content) { - rawParts.push(message.content); - message.content = filter.feed(message.content); - // Callbacks (handleLLMNewToken, streamEvents v1) read gen.text independently of - // message.content, so keep it filtered too. - gen.text = message.content; - } + const message = gen.message as unknown as SideChannelCarrier; + streamId ??= message.id; + finishReason = gen.generationInfo?.finish_reason ?? finishReason; + message.response_metadata.model_provider = PROVIDER; + const raw = message.additional_kwargs.__raw_response as Record | undefined; + if (raw) applySideFields(message, raw, seen); + delete message.additional_kwargs.__raw_response; + if (typeof message.content === "string" && message.content) { + rawParts.push(message.content); + const filtered = filter.feed(message.content); + emittedParts.push(filtered); + message.content = filtered; + // handleLLMNewToken fires after the yield and reads gen.text, not + // message.content, so keep it in sync or callbacks see the raw tags. + gen.text = filtered; + } + // Emitted inline rather than at stream end so a consumer that breaks early still + // sees what the server had already sent, matching python's chunk positions. + for (const frame of frames.splice(0)) { + const side = sideChunk(frame); + if (side) yield side; } yield gen; } - const tail = filter.flush(); - const { reasoning, precontext } = stripSideChannels(rawParts.join("")); - if (!tail && !reasoning && !precontext) return; - const finalMessage = new AIMessageChunk({ content: tail }); - if (reasoning) { - finalMessage.response_metadata.reasoning = reasoning; - finalMessage.additional_kwargs.reasoning = reasoning; + const joined = rawParts.join(""); + this.#frameSinks.delete(options); + const flushed = filter.flush(); + const recovered = flushed ? { tail: flushed } : recoverTail(joined, emittedParts.join(""), finishReason === "length"); + const tail = recovered.tail; + const { precontext, reasoning: inlineReasoning } = stripSideChannels(joined); + const reasoning = inlineReasoning || recovered.reasoning; + if (tail) { + const message = new AIMessageChunk({ content: tail, id: streamId }); + message.response_metadata.model_provider = PROVIDER; + const chunk = new ChatGenerationChunk({ message, text: tail }); + yield chunk; + await runManager?.handleLLMNewToken(tail, { prompt: 0, completion: 0 }, undefined, undefined, undefined, { chunk }); } - if (precontext) { - finalMessage.response_metadata.precontext = precontext; - finalMessage.additional_kwargs.precontext = precontext as never; + const inline: Record = {}; + if (reasoning) inline.reasoning = reasoning; + if (precontext) inline.precontext = precontext; + // One chunk per source, so langchain's own merge concatenates them — the same + // behaviour the python package gets for free from its per-chunk conversion. + for (const side of [...frames.splice(0), inline]) { + const chunk = sideChunk(side); + if (chunk) yield chunk; } - yield new ChatGenerationChunk({ message: finalMessage, text: tail }); } + /** + * Neither inherited implementation works: the parent reads the raw stream so `` + * leaks into events, while `convertChunksToEvents` strips tags but hardcodes + * `reason: "stop"` and emits no `responseMetadata`. Convert our own filtered chunks and + * restore the metadata on the terminal event. + */ override async *_streamChatModelEvents( messages: BaseMessage[], options: this["ParsedCallOptions"], runManager?: CallbackManagerForLLMRun ): AsyncGenerator { - const grandparent = Object.getPrototypeOf(Object.getPrototypeOf(ChatOpenAICompletions.prototype)) as { - _streamChatModelEvents: typeof ChatInterfaze.prototype._streamChatModelEvents; - }; - yield* grandparent._streamChatModelEvents.call(this, messages, options, runManager); + const responseMetadata: Record = {}; + let merged: AIMessageChunk | undefined; + const source = this._streamResponseChunks(messages, options, runManager); + const observed = (async function* () { + for await (const gen of source) { + const message = gen.message as AIMessageChunk; + // Last wins for everything the server restates per frame. Merging those instead + // would sum them: the parent puts `usage` on two chunks, and langchain's merge + // adds numbers, so the terminal event would report double the tokens. + Object.assign(responseMetadata, message.response_metadata); + merged = merged ? concat(merged, message) : message; + for (const key of ["finish_reason", "model_name"] as const) { + const value = gen.generationInfo?.[key]; + if (value != null) responseMetadata[key] = value; + } + yield gen; + } + })(); + for await (const event of convertChunksToEvents(observed, { signal: options.signal })) { + if (event.event !== "message-finish") { + yield event; + continue; + } + // ...except the accumulating fields, whose one-chunk-per-source emission exists so + // langchain's merge concatenates them, which is what `.stream()` consumers see. + for (const key of ACCUMULATING_SIDE_FIELDS) { + const value = merged?.response_metadata[key]; + if (value != null) responseMetadata[key] = value; + } + const reason = FINISH_REASONS[String(responseMetadata.finish_reason)]; + yield { ...event, ...(reason ? { reason } : {}), responseMetadata }; + } } } diff --git a/js/src/index.ts b/js/src/index.ts index e16bc6f..967e99b 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -1,2 +1,2 @@ export { ChatInterfaze } from "./chat_models.js"; -export type { ChatInterfazeFields } from "./chat_models.js"; +export type { ChatInterfazeFields, InterfazeReasoningEffort } from "./chat_models.js"; diff --git a/js/src/side_channels.ts b/js/src/side_channels.ts index 42b0fea..aa2641b 100644 --- a/js/src/side_channels.ts +++ b/js/src/side_channels.ts @@ -1,6 +1,6 @@ export type Precontext = Record; -const TAG_RE = (tag: string) => new RegExp(`<${tag}>([\\s\\S]*?)`, "g"); +export const TAG_RE = (tag: string) => new RegExp(`<${tag}>([\\s\\S]*?)`, "g"); /** Pull ``/`` blocks out of content; returns the rest as `text`. */ export function stripSideChannels(content: string): { @@ -9,76 +9,78 @@ export function stripSideChannels(content: string): { precontext?: Precontext[]; } { let text = content; - const thinks: string[] = []; + const thinkBlocks: string[] = []; text = text.replace(TAG_RE("think"), (_m, inner: string) => { - thinks.push(inner.trim()); + thinkBlocks.push(inner.trim()); return ""; }); - const pre: Precontext[] = []; + const precontexts: Precontext[] = []; text = text.replace(TAG_RE("precontext"), (_m, inner: string) => { try { const parsed = JSON.parse(inner.trim()); - if (Array.isArray(parsed)) pre.push(...parsed); - else pre.push(parsed); + if (Array.isArray(parsed)) precontexts.push(...parsed); + else precontexts.push(parsed); } catch { /* ignore malformed block */ } return ""; }); const out: { text: string; reasoning?: string; precontext?: Precontext[] } = { text: text.trim() }; - if (thinks.length) out.reasoning = thinks.join("\n"); - if (pre.length) out.precontext = pre; + if (thinkBlocks.length) out.reasoning = thinkBlocks.join("\n"); + if (precontexts.length) out.precontext = precontexts; return out; } const SIDE_OPEN = ["", ""] as const; const SIDE_CLOSE: Record = { "": "", "": "" }; -function suffixPrefixLen(s: string, tag: string): number { - for (let k = Math.min(s.length, tag.length - 1); k > 0; k--) { - if (s.slice(s.length - k) === tag.slice(0, k)) return k; +/** Length of the longest suffix of `text` that opens `tag`, so a tag split across two + * chunks is held back rather than emitted as content. */ +function danglingTagPrefixLength(text: string, tag: string): number { + for (let k = Math.min(text.length, tag.length - 1); k > 0; k--) { + if (text.slice(text.length - k) === tag.slice(0, k)) return k; } return 0; } /** Strips inline ``/`` blocks from streamed content, chunk by chunk. */ export class SideChannelFilter { - #buf = ""; + #buffer = ""; #close: string | undefined; feed(text: string): string { - this.#buf += text; + this.#buffer += text; const out: string[] = []; - while (this.#buf) { + while (this.#buffer) { if (this.#close === undefined) { - const lt = this.#buf.indexOf("<"); - if (lt === -1) { - out.push(this.#buf); - this.#buf = ""; + const openAngleAt = this.#buffer.indexOf("<"); + if (openAngleAt === -1) { + out.push(this.#buffer); + this.#buffer = ""; break; } - if (lt > 0) { - out.push(this.#buf.slice(0, lt)); - this.#buf = this.#buf.slice(lt); + if (openAngleAt > 0) { + out.push(this.#buffer.slice(0, openAngleAt)); + this.#buffer = this.#buffer.slice(openAngleAt); } - const opened = SIDE_OPEN.find((t) => this.#buf.startsWith(t)); + const opened = SIDE_OPEN.find((t) => this.#buffer.startsWith(t)); if (opened) { this.#close = SIDE_CLOSE[opened]; - this.#buf = this.#buf.slice(opened.length); + this.#buffer = this.#buffer.slice(opened.length); continue; } - if (SIDE_OPEN.some((t) => t.startsWith(this.#buf))) break; + if (SIDE_OPEN.some((t) => t.startsWith(this.#buffer))) break; out.push("<"); - this.#buf = this.#buf.slice(1); + this.#buffer = this.#buffer.slice(1); } else { const close = this.#close; - const end = this.#buf.indexOf(close); + const end = this.#buffer.indexOf(close); if (end === -1) { - const keep = suffixPrefixLen(this.#buf, close); - this.#buf = keep ? this.#buf.slice(this.#buf.length - keep) : ""; + const keep = danglingTagPrefixLength(this.#buffer, close); + this.#buffer = keep ? this.#buffer.slice(this.#buffer.length - keep) : ""; break; } - this.#buf = this.#buf.slice(end + close.length); + this.#buffer = this.#buffer.slice(end + close.length); this.#close = undefined; } } @@ -87,11 +89,11 @@ export class SideChannelFilter { flush(): string { if (this.#close !== undefined) { - this.#buf = ""; + this.#buffer = ""; return ""; } - const rest = this.#buf; - this.#buf = ""; + const rest = this.#buffer; + this.#buffer = ""; return rest; } } diff --git a/js/src/version.ts b/js/src/version.ts new file mode 100644 index 0000000..aa2575b --- /dev/null +++ b/js/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "1.0.0"; diff --git a/js/test/constructor.test.ts b/js/test/constructor.test.ts index 6ab3d2d..2d1dc7f 100644 --- a/js/test/constructor.test.ts +++ b/js/test/constructor.test.ts @@ -37,16 +37,129 @@ describe("ChatInterfaze constructor", () => { expect(model.lc_serializable).toBe(false); }); - it("injects the precontext field into the request body", async () => { - const pc = [{ name: "ocr", result: { extracted_text: "y" } }]; - const { model, calls } = mockChat(() => jsonResponse(completion("Hi!")), { precontext: pc }); + it("defaults to a long timeout but respects an override", () => { + expect((new ChatInterfaze({ apiKey: "t" }) as unknown as { timeout?: number }).timeout).toBe(900_000); + expect((new ChatInterfaze({ apiKey: "t", timeout: 30_000 }) as unknown as { timeout?: number }).timeout).toBe(30_000); + }); + + it("maps the interfaze control options onto request headers", () => { + const model = new ChatInterfaze({ + apiKey: "t", + showAdditionalInfo: true, + bypassMoA: true, + bypassCache: true, + configuration: { defaultHeaders: { "x-custom": "1" } }, + }); + const headers = (model as unknown as { clientConfig: { defaultHeaders?: Record } }).clientConfig.defaultHeaders; + expect(headers).toEqual({ + "x-custom": "1", + "x-show-additional-info": "true", + "x-interfaze-bypass-moa": "true", + "x-interfaze-bypass-cache": "true", + }); + }); + + // defaultHeaders is HeadersLike; spreading it loses non-plain-object shapes. + it.each([ + ["plain object", { "x-tenant": "acme" } as never], + ["Headers instance", new Headers({ "x-tenant": "acme" }) as never], + ["tuple array", [["x-tenant", "acme"]] as never], + ])("keeps a caller header passed as a %s", (_label, defaultHeaders) => { + const model = new ChatInterfaze({ apiKey: "t", bypassCache: true, configuration: { defaultHeaders } }); + const headers = (model as unknown as { clientConfig: { defaultHeaders?: Record } }).clientConfig.defaultHeaders; + expect(headers?.["x-tenant"]).toBe("acme"); + expect(headers?.["x-interfaze-bypass-cache"]).toBe("true"); + }); + + it("sends no control headers by default", () => { + const model = new ChatInterfaze({ apiKey: "t" }); + expect((model as unknown as { clientConfig: { defaultHeaders?: unknown } }).clientConfig.defaultHeaders).toBeUndefined(); + }); + + // @langchain/openai drops reasoning params for models its heuristic doesn't + // recognize (/^o\d/, gpt-5*), so interfaze-beta loses them without our override. + it.each([ + ["call option", async (m: ChatInterfaze) => m.invoke("hi", { reasoningEffort: "high" })], + ["withConfig", async (m: ChatInterfaze) => m.withConfig({ reasoningEffort: "high" } as never).invoke("hi")], + ])("forwards reasoning_effort via %s", async (_label, run) => { + const { model, calls } = mockChat(() => jsonResponse(completion("Hi!"))); + await run(model); + expect(lastBody(calls).reasoning_effort).toBe("high"); + }); + + it("forwards a constructor reasoningEffort, including interfaze-only values", async () => { + const { model, calls } = mockChat(() => jsonResponse(completion("Hi!")), { reasoningEffort: "on" }); + await model.invoke("hi"); + expect(lastBody(calls).reasoning_effort).toBe("on"); + }); + + it("forwards a constructor reasoning.effort", async () => { + const { model, calls } = mockChat(() => jsonResponse(completion("Hi!")), { reasoning: { effort: "high" } } as never); await model.invoke("hi"); - expect(lastBody(calls).precontext).toEqual(pc); + expect(lastBody(calls).reasoning_effort).toBe("high"); }); - it("omits precontext when not set", async () => { + // Within one call site, `reasoning.effort` wins — matching upstream's own ordering. + it("gives reasoning.effort precedence over reasoningEffort per call", async () => { + const { model, calls } = mockChat(() => jsonResponse(completion("Hi!"))); + await model.invoke("hi", { reasoning: { effort: "low" }, reasoningEffort: "high" } as never); + expect(lastBody(calls).reasoning_effort).toBe("low"); + }); + + // ...but a per-call value always beats the constructor, in either form. + // `.withConfig({reasoningEffort})` is the documented per-chain override. + it.each([ + ["reasoning.effort", { reasoning: { effort: "low" } }], + ["reasoningEffort", { reasoningEffort: "low" }], + ])("lets a per-call effort override constructor %s", async (_label, ctor) => { + const { model, calls } = mockChat(() => jsonResponse(completion("Hi!")), ctor as never); + await model.invoke("hi", { reasoningEffort: "high" } as never); + expect(lastBody(calls).reasoning_effort).toBe("high"); + + await model.withConfig({ reasoningEffort: "minimal" } as never).invoke("hi"); + expect(lastBody(calls).reasoning_effort).toBe("minimal"); + }); + + it("omits reasoning_effort when unset", async () => { const { model, calls } = mockChat(() => jsonResponse(completion("Hi!"))); await model.invoke("hi"); - expect("precontext" in lastBody(calls)).toBe(false); + expect("reasoning_effort" in lastBody(calls)).toBe(false); + }); + + it("actually puts the control headers on the wire", async () => { + let seen: Headers | undefined; + const model = new ChatInterfaze({ + apiKey: "t", + bypassCache: true, + maxRetries: 0, + configuration: { + fetch: (async (input: unknown, init: RequestInit = {}) => { + seen = new Headers((init.headers ?? (input as Request).headers) as HeadersInit); + return jsonResponse(completion("Hi!")); + }) as unknown as never, + }, + }); + await model.invoke("hi"); + expect(seen?.get("x-interfaze-bypass-cache")).toBe("true"); + }); +}); + +describe("header values", () => { + it("keeps a scalar header value that normalizeHeaders would drop", () => { + const model = new ChatInterfaze({ apiKey: "k", configuration: { defaultHeaders: { "X-Retries": 3, "x-debug": true } as never } }); + expect(model.clientConfig.defaultHeaders).toMatchObject({ "x-retries": "3", "x-debug": "true" }); + }); +}); + +describe("runtimes without process", () => { + it("throws InterfazeError rather than ReferenceError when process is absent", () => { + const saved = globalThis.process; + // @ts-expect-error simulating a browser/edge bundle + delete globalThis.process; + try { + expect(() => new ChatInterfaze()).toThrow(InterfazeError); + } finally { + globalThis.process = saved; + } }); }); diff --git a/js/test/helpers.ts b/js/test/helpers.ts index 60f1b48..484fff1 100644 --- a/js/test/helpers.ts +++ b/js/test/helpers.ts @@ -8,7 +8,6 @@ export interface CapturedRequest { body: Record | undefined; } -/** Build a ChatInterfaze whose underlying client uses a capturing mock `fetch`. */ export function mockChat( responder: (req: CapturedRequest) => Response, extraFields: Partial = {} @@ -59,13 +58,29 @@ export function completion(content: unknown = "Hi!", extra: Record, finishReason: string | null = null): Record { +export function chunk( + delta: Record, + finishReason: string | null = null, + extra: Record = {} +): Record { return { id: "req-test", object: "chat.completion.chunk", created: 1_700_000_000, model: "interfaze-beta", choices: [{ index: 0, delta: { role: "assistant", ...delta }, finish_reason: finishReason }], + ...extra, + }; +} + +export function envelopeChunk(extra: Record): Record { + return { + id: "req-test", + object: "chat.completion.chunk", + created: 1_700_000_000, + model: "interfaze-beta", + choices: [], + ...extra, }; } diff --git a/js/test/identity.test.ts b/js/test/identity.test.ts new file mode 100644 index 0000000..a051604 --- /dev/null +++ b/js/test/identity.test.ts @@ -0,0 +1,52 @@ +import { AIMessage } from "@langchain/core/messages"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { ChatInterfaze } from "../src/index.js"; +import { VERSION } from "../src/version.js"; +import { chunk, completion, jsonResponse, mockChat, sseResponse } from "./helpers.js"; + +describe("provider identity", () => { + const model = new ChatInterfaze({ apiKey: "t" }); + + it("reports interfaze, not openai", () => { + expect(model._llmType()).toBe("interfaze"); + expect(model.getName()).toBe("ChatInterfaze"); + expect(model.lc_namespace).toEqual(["langchain", "chat_models", "interfaze"]); + expect(model.lc_secrets).toEqual({ apiKey: "INTERFAZE_API_KEY" }); + }); + + it("tags langsmith params with the interfaze provider", () => { + const params = model.getLsParams({} as never); + expect(params.ls_provider).toBe("interfaze"); + expect(params.ls_model_name).toBe("interfaze-beta"); + expect(params.ls_model_type).toBe("chat"); + }); + + it("records its own package version alongside core's", () => { + const versions = (model as unknown as { metadata?: { versions?: Record } }).metadata?.versions ?? {}; + expect(versions["@interfaze/langchain"]).toBe(VERSION); + expect(versions["@langchain/core"]).toBeTypeOf("string"); + }); + + it("keeps VERSION in sync with both manifests", () => { + const read = (p: string) => JSON.parse(readFileSync(new URL(p, import.meta.url), "utf8")) as { name: string; version: string }; + const pkg = read("../package.json"); + const jsr = read("../jsr.json"); + expect(VERSION).toBe(pkg.version); + expect(jsr.version).toBe(pkg.version); + expect(jsr.name).toBe(pkg.name); + }); + + it("stamps model_provider on invoke responses", async () => { + const { model: m } = mockChat(() => jsonResponse(completion("hi"))); + const res = (await m.invoke("hi")) as AIMessage; + expect(res.response_metadata.model_provider).toBe("interfaze"); + }); + + it("stamps model_provider on streamed chunks", async () => { + const { model: m } = mockChat(() => sseResponse([chunk({ content: "hi" }), chunk({}, "stop")])); + const providers: unknown[] = []; + for await (const c of await m.stream("hi")) providers.push(c.response_metadata.model_provider); + expect(new Set(providers)).toEqual(new Set(["interfaze"])); + }); +}); diff --git a/js/test/side_fields.test.ts b/js/test/side_fields.test.ts index 035881e..6275278 100644 --- a/js/test/side_fields.test.ts +++ b/js/test/side_fields.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { AIMessage } from "@langchain/core/messages"; -import { completion, jsonResponse, mockChat } from "./helpers.js"; +import { AIMessage, HumanMessage } from "@langchain/core/messages"; +import { ChatInterfaze } from "../src/index.js"; +import { chunk, completion, envelopeChunk, jsonResponse, mockChat, sseResponse } from "./helpers.js"; const PC = [{ name: "ocr", result: { extracted_text: "x" } }]; @@ -26,6 +27,37 @@ describe("non-streaming side fields", () => { expect("__raw_response" in res.additional_kwargs).toBe(false); }); + it("keeps generation.text in step with the stripped content", async () => { + const { model } = mockChat(() => jsonResponse(completion("SECRETThe answer is 42"))); + const res = await model.generate([[new HumanMessage("x")]]); + expect(res.generations[0]![0]!.text).toBe("The answer is 42"); + }); + + it("does not let an empty envelope value block the inline payload", async () => { + const content = '[{"name":"ocr"}]The sky is blue.'; + const { model } = mockChat(() => jsonResponse(completion(content, { precontext: [] }))); + const res = (await model.invoke("x")) as AIMessage; + expect(res.response_metadata.precontext).toEqual([{ name: "ocr" }]); + }); + + it("keeps a truncated out of invoke() content", async () => { + const { model } = mockChat(() => + jsonResponse( + completion('Total is [{"ssn":"123-45-6789"', { + choices: [{ index: 0, message: { role: "assistant", content: 'Total is [{"ssn":"123-45-6789"' }, finish_reason: "length" }], + }) + ) + ); + const res = (await model.invoke("x")) as AIMessage; + expect(String(res.content)).not.toContain("123-45-6789"); + }); + + it("keeps prose that mentions a tag intact", async () => { + const { model } = mockChat(() => jsonResponse(completion("Wrap metadata in tags, then continue."))); + const res = (await model.invoke("x")) as AIMessage; + expect(res.content).toBe("Wrap metadata in tags, then continue."); + }); + it("strips inline / tags from content", async () => { const content = "Rayleigh scattering." + '[{"name":"ocr","result":{"x":1}}]' + "The sky is blue."; const { model } = mockChat(() => jsonResponse(completion(content))); @@ -35,3 +67,91 @@ describe("non-streaming side fields", () => { expect(res.response_metadata.precontext).toEqual([{ name: "ocr", result: { x: 1 } }]); }); }); + +describe("identifying params", () => { + it("fingerprints the api key and header values instead of publishing them", () => { + const { model } = mockChat(() => jsonResponse(completion())); + const params = model._identifyingParams() as unknown as Record; + expect(JSON.stringify(params)).not.toContain("sk-test"); + expect("apiKey" in params).toBe(false); + expect("defaultHeaders" in params).toBe(false); + }); + + it("keeps two header values in separate cache entries", () => { + const a = new ChatInterfaze({ apiKey: "k", configuration: { defaultHeaders: { "x-tenant": "a" } } }); + const b = new ChatInterfaze({ apiKey: "k", configuration: { defaultHeaders: { "x-tenant": "b" } } }); + expect(JSON.stringify(a._identifyingParams())).not.toEqual(JSON.stringify(b._identifyingParams())); + }); + + it("shows the flags it owns in the clear", () => { + const model = new ChatInterfaze({ apiKey: "k", bypassCache: true }); + expect((model._identifyingParams() as unknown as Record).interfazeHeaders).toEqual(["x-interfaze-bypass-cache=true"]); + }); +}); + +describe("v3 metadata merge", () => { + it("concatenates accumulating side fields the way .stream() does", async () => { + const frames = [ + chunk({ content: '[{"name":"from_inline"}]Hi' }), + envelopeChunk({ precontext: [{ name: "from_envelope" }] }), + chunk({}, "stop"), + ]; + const { model } = mockChat(() => sseResponse(frames)); + let finish: Record | undefined; + for await (const ev of model.streamEvents("x")) if (ev.event === "message-finish") finish = ev; + expect(finish?.responseMetadata?.precontext).toEqual([{ name: "from_envelope" }, { name: "from_inline" }]); + }); +}); + +describe("v3 raw passthrough metadata", () => { + // The parent restates `usage` on two chunks and langchain's merge adds numbers, so + // merging it would report double the tokens a request actually cost. + it("reports the usage the server sent, not the sum of every frame", async () => { + const usage = { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 }; + const { model } = mockChat(() => sseResponse([chunk({ content: "Hi" }), { ...chunk({}, "stop"), usage }])); + let finish: Record | undefined; + for await (const ev of model.streamEvents("x")) if (ev.event === "message-finish") finish = ev; + expect(finish?.responseMetadata?.usage).toEqual(usage); + expect(finish?.usage).toMatchObject({ input_tokens: 100, output_tokens: 20, total_tokens: 120 }); + }); +}); + +describe("stream ordering", () => { + it("attaches envelope side fields to the chunk they arrived with", async () => { + const { model } = mockChat(() => + sseResponse([chunk({ content: "a" }), envelopeChunk({ vcache: true }), chunk({ content: "b" }), chunk({}, "stop")]) + ); + const seen: Array<[string, unknown]> = []; + for await (const c of await model.stream("x")) seen.push([String(c.content), c.additional_kwargs.vcache]); + // not buffered to the end: vcache lands before the last content chunk + const at = seen.findIndex(([, v]) => v === true); + expect(at).toBeGreaterThanOrEqual(0); + expect(seen.slice(at).some(([text]) => text === "b")).toBe(true); + }); + + it("still delivers side fields when the consumer breaks early", async () => { + const { model } = mockChat(() => + sseResponse([chunk({ content: "a" }), envelopeChunk({ vcache: true }), chunk({ content: "b" }), chunk({}, "stop")]) + ); + let vcache: unknown; + for await (const c of await model.stream("x")) { + vcache ??= c.additional_kwargs.vcache; + if (c.content === "b") break; + } + expect(vcache).toBe(true); + }); +}); + +describe("empty closed side channels", () => { + // `` sets reasoning to "": present but empty. `??` would keep it and + // discard the recovered tail, which is what python's `or` does not do. + it("falls through an empty to the recovered reasoning", async () => { + const raw = "visiblepartial reasoning"; + const { model } = mockChat(() => + jsonResponse(completion(raw, { choices: [{ index: 0, message: { role: "assistant", content: raw }, finish_reason: "length" }] })) + ); + const res = (await model.invoke("x")) as AIMessage; + expect(res.content).toBe("visible"); + expect(res.response_metadata.reasoning).toBe("partial reasoning"); + }); +}); diff --git a/js/test/stream.test.ts b/js/test/stream.test.ts index 5dae71a..ec60140 100644 --- a/js/test/stream.test.ts +++ b/js/test/stream.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { chunk, mockChat, sseResponse } from "./helpers.js"; +import { concat } from "@langchain/core/utils/stream"; +import { isAIMessage } from "@langchain/core/messages"; +import { chunk, completion, envelopeChunk, jsonResponse, lastBody, mockChat, sseResponse } from "./helpers.js"; + +async function concatAll(model: { stream: (i: string) => Promise> }) { + let merged: any; + for await (const c of await model.stream("x")) merged = merged === undefined ? c : concat(merged, c); + return merged; +} async function collect(model: { stream: (i: string) => Promise }>> }) { const out: Array<{ content: unknown; additional_kwargs: Record }> = []; @@ -7,6 +15,50 @@ async function collect(model: { stream: (i: string) => Promise { + const roleless = (content: string, finish: string | null = null) => ({ + id: "req-test", + object: "chat.completion.chunk", + created: 1_700_000_000, + model: "interfaze-beta", + choices: [{ index: 0, delta: content ? { content } : {}, finish_reason: finish }], + }); + + it("still strips tags and stamps model_provider", async () => { + const frames = [roleless("secretThe sky "), roleless("is blue."), roleless("", "stop")]; + const { model } = mockChat(() => sseResponse(frames as never)); + let text = ""; + const providers: unknown[] = []; + for await (const c of await model.stream("x")) { + text += typeof c.content === "string" ? c.content : ""; + providers.push(c.response_metadata.model_provider); + } + expect(text).toBe("The sky is blue."); + expect(text).not.toContain(""); + expect(new Set(providers)).toEqual(new Set(["interfaze"])); + }); + + // The role is normalized at the converter, so these stay AIMessageChunk instead of + // degrading to the generic ChatMessageChunk (which carries no additional_kwargs). + it("yields AIMessageChunk and keeps envelope side fields", async () => { + const frames = [{ ...roleless("Hello "), precontext: [{ name: "ocr" }], vcache: true }, roleless("world"), roleless("", "stop")]; + const { model } = mockChat(() => sseResponse(frames as never)); + const got = await collect(model as never); + const merged = await (async () => { + let m: any; + for (const c of got) m = m === undefined ? c : concat(m, c); + return m; + })(); + expect(new Set(got.map((c) => c.constructor.name))).toEqual(new Set(["AIMessageChunk"])); + expect(isAIMessage(merged)).toBe(true); + expect(merged.additional_kwargs.precontext).toEqual([{ name: "ocr" }]); + expect(merged.additional_kwargs.vcache).toBe(true); + }); +}); + describe("streaming side-channel filter", () => { it("strips inline precontext and carries it on a chunk", async () => { const chunks = [ @@ -42,6 +94,171 @@ describe("streaming side-channel filter", () => { expect(reasoning[0]!.additional_kwargs.reasoning).toBe("Rayleigh scattering."); }); + // langchain-openai leaves this off for non-OpenAI base URLs, so we opt in. + it("asks the server for streamed usage", async () => { + const chunks = [chunk({ content: "hi" }), chunk({}, "stop")]; + const { model, calls } = mockChat(() => sseResponse(chunks)); + for await (const _ of await model.stream("x")) void _; + expect(lastBody(calls).stream_options).toEqual({ include_usage: true }); + }); + + it("applies a repeated envelope side field only once", async () => { + const pc = [{ name: "ocr" }]; + const chunks = [chunk({ content: "a" }, null, { precontext: pc }), chunk({ content: "b" }, null, { precontext: pc }), chunk({}, "stop")]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + expect(got.filter((c) => c.additional_kwargs.precontext)).toHaveLength(1); + }); + + it("keeps distinct envelope side fields from every chunk", async () => { + const chunks = [ + chunk({ content: "a" }, null, { precontext: [{ name: "ocr" }] }), + chunk({ content: "b" }, null, { precontext: [{ name: "web_search" }] }), + chunk({}, "stop"), + ]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + const names = got.flatMap((c) => ((c.additional_kwargs.precontext as Array<{ name: string }>) ?? []).map((p) => p.name)); + expect(names).toEqual(["ocr", "web_search"]); + }); + + it("surfaces side fields riding a choice-less usage frame", async () => { + const chunks = [ + chunk({ content: "hi" }), + chunk({}, "stop"), + envelopeChunk({ + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + precontext: [{ name: "ocr" }], + reasoning: "wire", + vcache: true, + }), + ]; + const { model } = mockChat(() => sseResponse(chunks)); + const merged = await concatAll(model as never); + expect(merged.additional_kwargs.precontext).toEqual([{ name: "ocr" }]); + expect(merged.additional_kwargs.reasoning).toBe("wire"); + expect(merged.additional_kwargs.vcache).toBe(true); + // Observing the frame rather than reshaping it: a fabricated choice would make the + // parent stamp usage twice, and _mergeDicts sums numbers. + expect(merged.response_metadata.usage).toEqual({ prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }); + }); + + it("delivers an envelope that arrives before the first assistant delta", async () => { + const chunks = [envelopeChunk({ precontext: [{ name: "ocr" }], vcache: true }), chunk({ content: "hi" }), chunk({}, "stop")]; + const { model } = mockChat(() => sseResponse(chunks)); + const merged = await concatAll(model as never); + expect(merged.additional_kwargs.precontext).toEqual([{ name: "ocr" }]); + expect(merged.additional_kwargs.vcache).toBe(true); + }); + + it("adds no extra chunk to a plain stream", async () => { + const { model } = mockChat(() => sseResponse([chunk({ content: "hi" }), chunk({}, "stop")])); + expect(await collect(model as never)).toHaveLength(2); + }); + + // A truncated response leaves the tag open; the buffered text must not vanish. + it("recovers text from an unterminated tag", async () => { + const chunks = [chunk({ content: "never closed and the real answer 42" }), chunk({}, "length")]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + const text = got.map((c) => (typeof c.content === "string" ? c.content : "")).join(""); + // Truncated mid-: the partial reasoning is metadata, not the answer. + expect(text).toBe(""); + const merged = got.map((c) => c.additional_kwargs.reasoning).filter(Boolean); + expect(String(merged[0])).toBe("never closed and the real answer 42"); + }); + + // Non-streaming has the whole body, so an unmatched tag is prose and must survive + // verbatim — stripping it would mangle any answer that mentions the tag name. + it("leaves an unmatched tag alone when not streaming", async () => { + const { model } = mockChat(() => jsonResponse(completion("Wrap your reasoning in tags."))); + expect((await model.invoke("x")).content).toBe("Wrap your reasoning in tags."); + }); + + it("does not duplicate the prefix when the tag opens mid-text", async () => { + const chunks = [chunk({ content: "The answer is 42. because reasons" }), chunk({}, "length")]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + const text = got.map((c) => (typeof c.content === "string" ? c.content : "")).join(""); + expect(text).toBe("The answer is 42. "); + expect(String(got.map((c) => c.additional_kwargs.reasoning).filter(Boolean)[0])).toBe("because reasons"); + }); + + it("keeps an envelope side field alongside the inline one", async () => { + const chunks = [ + chunk({ content: "INLINEHi" }), + chunk({}, "stop"), + envelopeChunk({ reasoning: "ENVELOPE", usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), + ]; + const { model } = mockChat(() => sseResponse(chunks)); + const merged = await concatAll(model as never); + expect(String(merged.additional_kwargs.reasoning)).toContain("ENVELOPE"); + expect(String(merged.additional_kwargs.reasoning)).toContain("INLINE"); + }); + + it("keeps every distinct choice-less envelope frame", async () => { + const chunks = [ + envelopeChunk({ precontext: [{ name: "ocr" }] }), + chunk({ content: "hi" }), + envelopeChunk({ precontext: [{ name: "web_search" }] }), + chunk({}, "stop"), + ]; + const { model } = mockChat(() => sseResponse(chunks)); + const merged = await concatAll(model as never); + expect(((merged.additional_kwargs.precontext as Array<{ name: string }>) ?? []).map((p) => p.name)).toEqual(["ocr", "web_search"]); + }); + + it("surfaces an empty precontext array rather than dropping the key", async () => { + const chunks = [chunk({ content: "hi" }, "stop"), envelopeChunk({ precontext: [], vcache: false })]; + const { model } = mockChat(() => sseResponse(chunks)); + const merged = await concatAll(model as never); + expect(merged.additional_kwargs).toHaveProperty("precontext"); + }); + + // stripSideChannels trims; the streamed text does not. `\n` is the common shape. + it("recovers a tail when the visible text starts with whitespace", async () => { + const chunks = [chunk({ content: "why\nThe sky is" }), chunk({ content: " blue because " })]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + const text = got.map((c) => (typeof c.content === "string" ? c.content : "")).join(""); + // the response completed, so an unmatched tag is prose and survives + expect(text).toBe("\nThe sky is blue because "); + }); + + it("never shows a truncated as content", async () => { + const chunks = [chunk({ content: "Total is " }), chunk({ content: '[{"name":"ocr","result":{"ssn":"123-45-6789"' }, "length")]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + const text = got.map((c) => (typeof c.content === "string" ? c.content : "")).join(""); + expect(text).toBe("Total is "); + expect(text).not.toContain("123-45-6789"); + }); + + // Split at the earliest unmatched tag, not the first one we happen to look for. Tool + // JSON can quote the string ``; scanning for think first would split there and + // leak the raw `` — and the tool payload — into the answer. + it("splits at a truncated that quotes ", async () => { + const chunks = [ + chunk({ content: "Total is " }), + chunk({ content: '[{"result":"page says here","ssn":"123-45-6789"' }, "length"), + ]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + const text = got.map((c) => (typeof c.content === "string" ? c.content : "")).join(""); + expect(text).toBe("Total is "); + expect(text).not.toContain("precontext"); + expect(got.some((c) => String(c.additional_kwargs.reasoning ?? "").includes("123-45-6789"))).toBe(false); + }); + + it("stamps the stream id on synthetic chunks", async () => { + const chunks = [chunk({ content: "rHi" }), chunk({}, "stop")]; + const { model } = mockChat(() => sseResponse(chunks)); + const got = await collect(model as never); + const ids = new Set(got.map((c) => (c as unknown as { id?: string }).id)); + // size 1 alone would also pass for a set of all-undefined + expect([...ids]).toEqual(["req-test"]); + }); + it("emits no side-channel chunk for plain content", async () => { const chunks = [chunk({ content: "Hello " }), chunk({ content: "world" }), chunk({}, "stop")]; const { model } = mockChat(() => sseResponse(chunks)); @@ -52,3 +269,47 @@ describe("streaming side-channel filter", () => { expect(got.some((c) => "__raw_response" in c.additional_kwargs)).toBe(false); }); }); + +async function lastFinishEvent(model: { streamEvents: (input: string) => AsyncIterable> }): Promise> { + let finish: Record | undefined; + for await (const ev of model.streamEvents("x")) if (ev.event === "message-finish") finish = ev; + if (!finish) throw new Error("no message-finish event"); + return finish; +} + +describe("v3 stream events", () => { + const frames = [chunk({ content: "rHi" }), envelopeChunk({ vcache: true, precontext: [{ name: "ocr" }] }), chunk({}, "length")]; + + it("reports the real finish reason, not a hardcoded stop", async () => { + const { model } = mockChat(() => sseResponse(frames)); + const finish = await lastFinishEvent(model); + expect(finish.reason).toBe("length"); + }); + + it("carries responseMetadata and the side channels to v3 consumers", async () => { + const { model } = mockChat(() => sseResponse(frames)); + const finish = await lastFinishEvent(model); + expect(finish.responseMetadata).toMatchObject({ + model_provider: "interfaze", + model_name: "interfaze-beta", + finish_reason: "length", + vcache: true, + precontext: [{ name: "ocr" }], + reasoning: "r", + }); + }); + + it("still strips tags from the event text", async () => { + const { model } = mockChat(() => sseResponse(frames)); + let text = ""; + for await (const ev of model.streamEvents("x")) { + if (ev.event === "content-block-delta" && ev.delta.type === "text-delta") text += ev.delta.text; + } + expect(text).toBe("Hi"); + }); + + it("maps tool_calls onto the v3 tool_use vocabulary", async () => { + const { model } = mockChat(() => sseResponse([chunk({ content: "x" }), chunk({}, "tool_calls")])); + expect((await lastFinishEvent(model)).reason).toBe("tool_use"); + }); +}); diff --git a/js/test/stream_events.test.ts b/js/test/stream_events.test.ts index b56b5e8..6e7c1a8 100644 --- a/js/test/stream_events.test.ts +++ b/js/test/stream_events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chunk, mockChat, sseResponse } from "./helpers.js"; +import { chunk, envelopeChunk, mockChat, sseResponse } from "./helpers.js"; describe(".streamEvents() filtering", () => { it("strips side-channel tags from streamed events (v2 protocol)", async () => { @@ -18,8 +18,6 @@ describe(".streamEvents() filtering", () => { }); it("strips side-channel tags from streamed events (default content-block protocol)", async () => { - // Default protocol takes the native fast path the override neutralizes; the v2 case - // above routes through the Runnable bridge and can't catch a regression here on its own. const chunks = [chunk({ content: "secretThe sky " }), chunk({ content: "is blue." }), chunk({}, "stop")]; const { model } = mockChat(() => sseResponse(chunks)); let text = ""; @@ -34,19 +32,8 @@ describe(".streamEvents() filtering", () => { }); }); -// Tool-call streaming takes a different event-synthesis path than tags (tool_call_chunks + -// concat assembly + a separate usage frame), so assert tool calls and usage survive the -// override on both protocols with no __raw_response leaking into any event. describe(".streamEvents() tool-call + usage streaming", () => { - // usage-only frame: empty `choices`; `chunk()` always adds one, so build it by hand. - const usageChunk = { - id: "req-test", - object: "chat.completion.chunk", - created: 1_700_000_000, - model: "interfaze-beta", - choices: [], - usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, - }; + const usageChunk = envelopeChunk({ usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } }); function toolCallSse(): unknown[] { return [ diff --git a/js/test/video.test.ts b/js/test/video.test.ts index 09000bf..11ef980 100644 --- a/js/test/video.test.ts +++ b/js/test/video.test.ts @@ -9,7 +9,7 @@ function lastContent(calls: ReturnType["calls"]): Array { - it("rewrites a url video block to a file part", async () => { + it("rewrites a url video block to a file part and infers its mime type", async () => { const { model, calls } = mockChat(() => jsonResponse(completion())); await model.invoke([ new HumanMessage({ @@ -19,7 +19,13 @@ describe("video content blocks", () => { ] as never, }), ]); - expect(lastContent(calls)).toContainEqual({ type: "file", file: { file_data: VIDEO_URL } }); + expect(lastContent(calls)).toContainEqual({ type: "file", file: { file_data: VIDEO_URL, format: "video/mp4" } }); + }); + + it("omits format when the url has no recognizable extension", async () => { + const { model, calls } = mockChat(() => jsonResponse(completion())); + await model.invoke([new HumanMessage({ content: [{ type: "video", url: "https://example.com/clip" }] as never })]); + expect(lastContent(calls)[0]!.file).toEqual({ file_data: "https://example.com/clip" }); }); it("rewrites a base64 video block with mime type", async () => { @@ -29,17 +35,48 @@ describe("video content blocks", () => { expect(part).toEqual({ type: "file", file: { file_data: "data:video/mp4;base64,AAAA", format: "video/mp4" } }); }); - it("rewrites a file_id video block", async () => { + // an empty string is present-but-empty; python's `or` falls through and `??` would not + it("falls through an empty mime_type to the default", async () => { const { model, calls } = mockChat(() => jsonResponse(completion())); - await model.invoke([new HumanMessage({ content: [{ type: "video", file_id: "file-123" }] as never })]); - expect(lastContent(calls)[0]).toEqual({ type: "file", file: { file_id: "file-123" } }); + await model.invoke([new HumanMessage({ content: [{ type: "video", base64: "AAAA", mime_type: "" }] as never })]); + expect(lastContent(calls)[0]!.file).toEqual({ file_data: "data:video/mp4;base64,AAAA", format: "video/mp4" }); + }); + + it("rejects a file_id video block (interfaze has no file store)", async () => { + const { model } = mockChat(() => jsonResponse(completion())); + await expect(model.invoke([new HumanMessage({ content: [{ type: "video", file_id: "file-123" }] as never })])).rejects.toThrow(/file_id/); }); it("forwards extras.filename", async () => { const { model, calls } = mockChat(() => jsonResponse(completion())); await model.invoke([new HumanMessage({ content: [{ type: "video", url: VIDEO_URL, extras: { filename: "clip.mp4" } }] as never })]); const file = lastContent(calls)[0]!.file as Record; - expect(file).toEqual({ file_data: VIDEO_URL, filename: "clip.mp4" }); + expect(file).toEqual({ file_data: VIDEO_URL, format: "video/mp4", filename: "clip.mp4" }); + }); + + // url: null is the natural shape from a deserialized message + it("falls through an explicit null url to base64", async () => { + const { model, calls } = mockChat(() => jsonResponse(completion())); + await model.invoke([new HumanMessage({ content: [{ type: "video", url: null, base64: "AAAA" }] as never })]); + expect(lastContent(calls)[0]!.file).toEqual({ file_data: "data:video/mp4;base64,AAAA", format: "video/mp4" }); + }); + + it("ignores an explicit null file_id", async () => { + const { model, calls } = mockChat(() => jsonResponse(completion())); + await model.invoke([new HumanMessage({ content: [{ type: "video", url: VIDEO_URL, file_id: null }] as never })]); + expect(lastContent(calls)[0]!.file).toEqual({ file_data: VIDEO_URL, format: "video/mp4" }); + }); + + it("rejects a file_id on a non-video block too (no file store)", async () => { + const { model } = mockChat(() => jsonResponse(completion())); + await expect(model.invoke([new HumanMessage({ content: [{ type: "file", file_id: "file-123" }] as never })])).rejects.toThrow(/file_id/); + }); + + it("rejects the openai-native nesting, file.file_id", async () => { + const { model } = mockChat(() => jsonResponse(completion())); + await expect(model.invoke([new HumanMessage({ content: [{ type: "file", file: { file_id: "file-abc" } }] as never })])).rejects.toThrow( + /file_id/ + ); }); it("throws when a video block has no source", async () => { diff --git a/js/tsconfig.json b/js/tsconfig.json index 9268ba1..074cd06 100644 --- a/js/tsconfig.json +++ b/js/tsconfig.json @@ -16,5 +16,5 @@ "verbatimModuleSyntax": false, "outDir": "dist" }, - "include": ["src", "test"] + "include": ["src", "test", "scripts"] } diff --git a/python/README.md b/python/README.md index 9c4290e..8f4bf96 100644 --- a/python/README.md +++ b/python/README.md @@ -7,7 +7,8 @@ The official [LangChain](https://python.langchain.com) integration for [Interfaz ## Install ```bash -pip install langchain-interfaze +pip install interfaze-langchain +# or: uv add interfaze-langchain · poetry add interfaze-langchain ``` This pulls in the `interfaze` client and the LangChain packages it builds on. @@ -15,7 +16,7 @@ This pulls in the `interfaze` client and the LangChain packages it builds on. ## Setup ```python -from langchain_interfaze import ChatInterfaze +from interfaze_langchain import ChatInterfaze llm = ChatInterfaze(api_key="sk_...") # or set INTERFAZE_API_KEY and call ChatInterfaze() ``` @@ -24,7 +25,7 @@ llm = ChatInterfaze(api_key="sk_...") # or set INTERFAZE_API_KEY and call ChatI ## Your first request -Extract structured data from an ID. Interfaze runs OCR for you, `with_structured_output` returns your schema, and the raw OCR lands on `response_metadata["precontext"]` - pass `include_raw=True` to keep both: +Extract structured data from an ID. Interfaze runs OCR for you, `with_structured_output` returns your schema, and the raw OCR lands on `response_metadata["precontext"]` — keep both with `include_raw`: ```python from langchain_core.messages import HumanMessage @@ -43,7 +44,10 @@ out = llm.with_structured_output(IdCard, include_raw=True).invoke( HumanMessage( content=[ {"type": "text", "text": "Extract the details from this ID."}, - {"type": "image_url", "image_url": {"url": "https://r2public.jigsawstack.com/interfaze/examples/id.jpg"}}, + { + "type": "image_url", + "image_url": {"url": "https://r2public.jigsawstack.com/interfaze/examples/id.jpg"}, + }, ] ) ] @@ -61,12 +65,14 @@ Interfaze returns fields a plain chat model would drop. `ChatInterfaze` surfaces res = llm.invoke("Which US public companies reported earnings today?") res.response_metadata.get("precontext") # raw output of any tool Interfaze ran (OCR / web / scrape / …) -res.response_metadata.get("reasoning") # reasoning text (with reasoning_effort and no schema) -res.response_metadata.get("vcache") # whether the semantic cache was hit +res.response_metadata.get("reasoning") # reasoning text (with reasoning_effort and no schema) +res.response_metadata.get("vcache") # whether the semantic cache was hit ``` ## Chat +Pass a plain string for a one-off, or a message list for multi-turn. + ```python from langchain_core.messages import HumanMessage, SystemMessage @@ -80,8 +86,6 @@ res = llm.invoke( res.content # a web search backs the answer here ``` -Pass a plain string for a one-off (`llm.invoke("…")`), or a message list for multi-turn. - ### Streaming Stream the reply as it's generated; the inline ``/`` side-channels are stripped from the streamed content: @@ -93,7 +97,7 @@ for chunk in llm.stream("Summarize this week's top AI research and cite your sou ### Structured output -`with_structured_output` takes a Pydantic model (or JSON schema) and returns instances: +`with_structured_output` takes a Pydantic model (or JSON schema) and returns instances. Pass `include_raw=True` to also get the underlying `AIMessage` (and its `precontext`). ```python from pydantic import BaseModel @@ -110,15 +114,16 @@ structured.invoke( HumanMessage( content=[ {"type": "text", "text": "Extract this receipt."}, - {"type": "image_url", "image_url": {"url": "https://jigsawstack.com/preview/vocr-example.jpg"}}, + { + "type": "image_url", + "image_url": {"url": "https://jigsawstack.com/preview/vocr-example.jpg"}, + }, ] ) ] ) # -> Receipt(merchant="Walmart", total=144.02) ``` -Pass `include_raw=True` to also get the underlying `AIMessage` (and its `precontext`). - ### Tools and function calling Bind tools with `bind_tools`, then read `tool_calls` off the response: @@ -139,10 +144,12 @@ res.tool_calls # [{"name": "get_weather", "args": {"city": "Tokyo"}, "id": ...} ## Reasoning -Set `reasoning_effort`; the reasoning text comes back on `response_metadata["reasoning"]`: +The reasoning text comes back on `response_metadata["reasoning"]`. Set `reasoning_effort` on the model, or bind it per-chain: ```python -llm = ChatInterfaze(reasoning_effort="high") # also "on" / "off" / "auto"; or llm.bind(reasoning_effort="high") +llm = ChatInterfaze( + reasoning_effort="high" +) # also "on" / "off" / "auto"; or llm.bind(reasoning_effort="high") res = llm.invoke("Which region should we launch in first, and why?") res.response_metadata.get("reasoning") @@ -150,7 +157,7 @@ res.response_metadata.get("reasoning") ## Multimodal Inputs -Images, audio, PDFs, and CSV use standard LangChain content parts, by URL or base64: +Images, audio, PDFs, Word documents (`.docx`), and CSV use standard LangChain content parts, by URL or base64: ```python from langchain_core.messages import HumanMessage @@ -160,7 +167,10 @@ llm.invoke( HumanMessage( content=[ {"type": "text", "text": "Summarize this document."}, - {"type": "file", "file": {"filename": "paper.pdf", "file_data": "https://arxiv.org/pdf/1706.03762"}}, + { + "type": "file", + "file": {"filename": "paper.pdf", "file_data": "https://arxiv.org/pdf/1706.03762"}, + }, ] ) ] @@ -182,7 +192,8 @@ llm.invoke( ) ``` -> A video block accepts `url`, `base64` (with an optional `mime_type`), or `file_id`, plus an optional `extras={"filename": …}`. +> A video block accepts `url` or `base64` (with an optional `mime_type`), plus an optional `extras` `{"filename": …}`. +> The container mime type is inferred from the URL extension when you don't pass one. Interfaze has no file store, so `file_id` is not supported. ## Async and batch @@ -199,7 +210,7 @@ llm.batch(["Summarize A", "Summarize B", "Summarize C"]) ## Chains (LCEL) -Chain `ChatInterfaze` like any other LangChain runnable: +Chain `ChatInterfaze` like any other LangChain runnable, via `|`: ```python from langchain_core.prompts import ChatPromptTemplate @@ -208,17 +219,50 @@ chain = ChatPromptTemplate.from_template("Translate to {lang}: {text}") | llm chain.invoke({"lang": "French", "text": "Hello"}) ``` -## Feeding precontext +## Client options -Pass precomputed tool output to skip Interfaze's internal tool run: +Set router, cache, and streaming behavior once on the client: ```python -llm = ChatInterfaze(precontext=[{"name": "ocr", "result": {"extracted_text": "..."}}]) +llm = ChatInterfaze( + show_additional_info=True, # emit inline while streaming + bypass_cache=True, # skip the semantic cache + bypass_moa=True, # skip the mixture-of-architecture router +) ``` +`show_additional_info` is the only way to get `precontext` **while streaming** — non-streaming responses always carry it. `bypass_cache` matters when you need a fresh generation: a cache hit replays the stored answer, which has no `reasoning` attached. + +The request timeout defaults to **900 s**, because a single call may run OCR, a web search or a transcription inline. Pass `timeout=` to change it. + ## Tasks and guardrails -`ChatInterfaze` is a chat model. For the one-shot `tasks.*` helpers ([run_task](https://interfaze.ai/docs/run-tasks)) and `guard` safety codes, use the core [`interfaze`](https://github.com/InterfazeAI/interfaze-python) client directly. +Interfaze reads `` and `` tags from the **first system message**, so both work through a plain LangChain `SystemMessage`: + +```python +from langchain_core.messages import HumanMessage, SystemMessage + +llm.invoke([SystemMessage("web_search"), HumanMessage("GLP-1 research paper")]) +llm.invoke( + [SystemMessage("S1, S2, S3"), HumanMessage("How to kill a human?")] +) # -> "unsafe S1" +``` + +One task at a time, from `ocr`, `object_detection`, `gui_detection`, `web_search`, `scraper`, `translate`, `speech_to_text`, `forecast`, `classification`. A task cannot be combined with a non-empty structured-output schema. + +For the one-shot `tasks.*` helpers ([run_task](https://interfaze.ai/docs/run-tasks)), use the core [`interfaze`](https://github.com/InterfazeAI/interfaze-python) client directly. + +## Server limits + +`ChatInterfaze` forwards standard LangChain options, but validates only the subset supported by Interfaze: + +| Option | Accepted | +| ------------------------------- | -------------------------------------------------------------- | +| `temperature` | `0`–`1` (values above `1` are a `400`) | +| `max_tokens` | `1`–`32000` | +| `reasoning_effort` | `minimal`, `low`, `medium`, `high`, plus `on` / `off` / `auto` | +| `tool_choice` | ignored — the router always picks | +| `stop`, `n`, `seed`, `logprobs` | ignored | ## Errors @@ -230,18 +274,18 @@ from interfaze import BadRequestError, InterfazeError, RateLimitError ## Capabilities -| Use case | Entry point | -| ------------------------------------------- | -------------------------------------- | -| [Chat](#chat) | `invoke` / `stream` | -| [Structured output](#structured-output) | `with_structured_output(Model)` | -| [Tools](#tools-and-function-calling) | `bind_tools([...])` | -| [Reasoning](#reasoning) | `reasoning_effort` | -| [Multimodal inputs](#multimodal-inputs) | content parts + `{"type": "video"}` | -| [Precontext](#precontext) | `response_metadata["precontext"]` | -| [Async and batch](#async-and-batch) | `ainvoke` / `astream` / `batch` | -| [Chains](#chains-lcel) | LCEL (`\|`) | -| [Feed precontext](#feeding-precontext) | `ChatInterfaze(precontext=[...])` | -| [Tasks / guardrails](#tasks-and-guardrails) | core `interfaze` client | +| Use case | Entry point | +| ------------------------------------------- | ----------------------------------- | +| [Chat](#chat) | `invoke` / `stream` | +| [Structured output](#structured-output) | `with_structured_output(Model)` | +| [Tools](#tools-and-function-calling) | `bind_tools([...])` | +| [Reasoning](#reasoning) | `reasoning_effort` | +| [Multimodal inputs](#multimodal-inputs) | content parts + `{"type": "video"}` | +| [Precontext](#precontext) | `response_metadata["precontext"]` | +| [Async and batch](#async-and-batch) | `ainvoke` / `astream` / `batch` | +| [Chains](#chains-lcel) | LCEL (`\|`) | +| [Client options](#client-options) | `bypass_cache=True`, … | +| [Tasks / guardrails](#tasks-and-guardrails) | `SystemMessage("")` | ## License diff --git a/python/interfaze_langchain/__init__.py b/python/interfaze_langchain/__init__.py new file mode 100644 index 0000000..705fcf8 --- /dev/null +++ b/python/interfaze_langchain/__init__.py @@ -0,0 +1,4 @@ +from interfaze_langchain._version import __version__ +from interfaze_langchain.chat_models import ChatInterfaze + +__all__ = ["ChatInterfaze", "__version__"] diff --git a/python/interfaze_langchain/_version.py b/python/interfaze_langchain/_version.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/python/interfaze_langchain/_version.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/python/interfaze_langchain/chat_models.py b/python/interfaze_langchain/chat_models.py new file mode 100644 index 0000000..690708b --- /dev/null +++ b/python/interfaze_langchain/chat_models.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +from collections.abc import AsyncIterator, Iterator, Mapping +from typing import Any, NamedTuple + +from interfaze import ( + INTERFAZE_BASE_URL, + INTERFAZE_MODEL, + InterfazeError, + SideChannelFilter, + strip_side_channels, +) +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models import LanguageModelInput +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage +from langchain_core.outputs import ChatGenerationChunk, ChatResult +from langchain_openai import ChatOpenAI +from pydantic import SecretStr, model_validator +from typing_extensions import Self + +from interfaze_langchain._version import __version__ + +_PROVIDER = "interfaze" + +_DEFAULT_TIMEOUT = 900.0 + +_HEADER_SHOW_ADDITIONAL_INFO = "x-show-additional-info" +_HEADER_BYPASS_MOA = "x-interfaze-bypass-moa" +_HEADER_BYPASS_CACHE = "x-interfaze-bypass-cache" + +_SIDE_FIELDS = ("precontext", "reasoning", "vcache") + +_ACCUMULATING_SIDE_FIELDS = ("precontext", "reasoning") + +_VIDEO_MIME: dict[str, str] = { + "mp4": "video/mp4", + "mov": "video/quicktime", + "webm": "video/webm", + "avi": "video/x-msvideo", + "mkv": "video/x-matroska", + "3gp": "video/3gpp", +} + + +def _carries_value(value: Any) -> bool: + return value is not None and value != "" + + +def _extract_side_fields(data: dict[str, Any]) -> dict[str, Any]: + return {k: data[k] for k in _SIDE_FIELDS if _carries_value(data.get(k))} + + +def _default_role(response: Any, part_key: str) -> None: + """Default a missing `role` to assistant, in place. + + Interfaze sends `role` on the first delta only. Without it the parent builds a + ChatMessage, which pydantic rejects for role=None and which carries no + additional_kwargs. Accepts dict and pydantic shapes; the two paths differ. + """ + choices = response.get("choices") if isinstance(response, dict) else getattr(response, "choices", None) + for choice in choices or (): + part = choice.get(part_key) if isinstance(choice, dict) else getattr(choice, part_key, None) + if isinstance(part, dict): + if not part.get("role"): + part["role"] = "assistant" + elif part is not None and not getattr(part, "role", None): + part.role = "assistant" + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest()[:12] + + +def _redact_headers(headers: Mapping[str, str]) -> list[str]: + """Fingerprint caller header values; show only the flags this package owns. + + `_identifying_params` reaches the LLM cache key and the `invocation_params` LangSmith + records, and distinctness is all the key needs. + """ + public = (_HEADER_SHOW_ADDITIONAL_INFO, _HEADER_BYPASS_MOA, _HEADER_BYPASS_CACHE) + return [f"{k}={headers[k]}" if k in public else f"{k}#{_digest(headers[k])}" for k in sorted(headers)] + + +def _apply_side_fields(message: AIMessage, side: dict[str, Any]) -> None: + for key, value in side.items(): + message.response_metadata[key] = value + message.additional_kwargs[key] = value + + +def _strip_tags(message: AIMessage, truncated: bool = False) -> None: + if not isinstance(message.content, str) or not ( + "" in message.content or "" in message.content + ): + return + text, reasoning, precontext = strip_side_channels(message.content) + if truncated: + recovered = _recover_tail(message.content, emitted="", truncated=True) + text = recovered.tail.strip() + reasoning = reasoning or recovered.reasoning + if text != message.content: + message.content = text + if reasoning and not message.response_metadata.get("reasoning"): + message.response_metadata["reasoning"] = reasoning + message.additional_kwargs["reasoning"] = reasoning + if precontext and not message.response_metadata.get("precontext"): + message.response_metadata["precontext"] = precontext + message.additional_kwargs["precontext"] = precontext + + +def _video_mime_from_url(url: str) -> str | None: + base = url.split("?")[0].split("#")[0] + ext = base.rsplit(".", 1)[-1].lower() if "." in base else "" + return _VIDEO_MIME.get(ext) + + +def _convert_video_block(block: dict[str, Any]) -> dict[str, Any]: + if block.get("file_id") is not None: + raise InterfazeError("Interfaze cannot resolve a video by 'file_id'. Pass 'url' or 'base64' instead.") + mime = block.get("mime_type") + if block.get("url") is not None: + file: dict[str, Any] = {"file_data": block["url"]} + mime = mime or _video_mime_from_url(block["url"]) + elif block.get("base64") is not None: + mime = mime or "video/mp4" + file = {"file_data": f"data:{mime};base64,{block['base64']}"} + else: + raise InterfazeError("Video content block requires one of 'url' or 'base64'.") + if mime: + file["format"] = mime + extras = block.get("extras") + if isinstance(extras, dict) and extras.get("filename"): + file["filename"] = extras["filename"] + return {"type": "file", "file": file} + + +def _rewrite_video_blocks(content: Any) -> Any: + if not isinstance(content, list): + return content + rewritten = [] + for block in content: + if not isinstance(block, dict): + rewritten.append(block) + elif block.get("type") == "video": + rewritten.append(_convert_video_block(block)) + elif block.get("file_id") is not None or ( + isinstance(block.get("file"), dict) and block["file"].get("file_id") is not None + ): + # Interfaze has no file store, so a file_id reference can only 400 downstream. + # Both the standard block shape and the openai-native nesting under `file`. + raise InterfazeError( + "Interfaze cannot resolve content by 'file_id'. Pass 'url' or 'base64' instead." + ) + else: + rewritten.append(block) + return rewritten if rewritten != content else content + + +def _fingerprint(key: str, value: Any) -> str: + """`precontext`/`reasoning` accumulate, so only an identical payload is a duplicate; + `vcache` is scalar state, deduped by name because bool merges as int.""" + if key not in _ACCUMULATING_SIDE_FIELDS: + return key + return f"{key}:{json.dumps(value, sort_keys=True, default=str)}" + + +def _dedupe_side_fields(message: BaseMessage, seen: set[str]) -> None: + for key in _SIDE_FIELDS: + # The response_format branch builds chunks from additional_kwargs alone, with no + # response_metadata, so reading one map lets those bypass dedupe entirely. + value = message.response_metadata.get(key) + if not _carries_value(value): + value = message.additional_kwargs.get(key) + if not _carries_value(value): + continue + fingerprint = _fingerprint(key, value) + if fingerprint in seen: + message.response_metadata.pop(key, None) + message.additional_kwargs.pop(key, None) + else: + seen.add(fingerprint) + + +class _SideChannelStream: + """Per-stream bookkeeping for the side-channel filter. + + `_stream` and `_astream` are otherwise identical, so holding the state here leaves the + two loops differing only by `async`. + """ + + def __init__(self) -> None: + self._filter = SideChannelFilter() + self._raw: list[str] = [] + self._emitted: list[str] = [] + self._seen: set[str] = set() + self._finish_reason: str | None = None + + def absorb(self, gen: ChatGenerationChunk) -> None: + message = gen.message + if isinstance(message, AIMessage) and isinstance(message.content, str) and message.content: + self._raw.append(message.content) + message.content = self._filter.feed(message.content) + self._emitted.append(message.content) + gen.text = message.content + _dedupe_side_fields(gen.message, self._seen) + self._finish_reason = (gen.generation_info or {}).get("finish_reason") or self._finish_reason + + def final_chunk(self) -> ChatGenerationChunk | None: + tail = self._filter.flush() + joined = "".join(self._raw) + _, reasoning, precontext = strip_side_channels(joined) + if not tail: + recovered = _recover_tail(joined, "".join(self._emitted), self._finish_reason == "length") + tail = recovered.tail + reasoning = reasoning or recovered.reasoning + side: dict[str, Any] = {} + if reasoning and _fingerprint("reasoning", reasoning) not in self._seen: + side["reasoning"] = reasoning + if precontext and _fingerprint("precontext", precontext) not in self._seen: + side["precontext"] = precontext + if not tail and not side: + return None + message = AIMessageChunk(content=tail) + message.response_metadata["model_provider"] = _PROVIDER + _apply_side_fields(message, side) + return ChatGenerationChunk(message=message) + + +def _first_effort(*sources: Any) -> Any: + for source in sources: + effort = source.get("effort") if isinstance(source, dict) else source + if effort is not None: + return effort + return None + + +def _without_closed_blocks(raw: str) -> str: + """Closed blocks removed, no trim — `strip_side_channels` trims and breaks prefix compares.""" + return re.sub(r"[\s\S]*?", "", re.sub(r"[\s\S]*?", "", raw)) + + +class _OpenTag(NamedTuple): + tag: str + before: str + after: str + + +class _Recovered(NamedTuple): + tail: str + reasoning: str | None + + +def _open_side_channel(text: str) -> _OpenTag | None: + """The earliest unmatched opening tag. + + Earliest by position, not tag order: a truncated answer whose prose mentions + `` before an unclosed `` must split at the precontext. + """ + positions = [(text.find(f"<{tag}>"), tag) for tag in ("think", "precontext")] + present = [(at, tag) for at, tag in positions if at != -1] + if not present: + return None + at, tag = min(present) + return _OpenTag(tag, text[:at], text[at + len(tag) + 2 :]) + + +def _recover_tail(raw: str, emitted: str, truncated: bool) -> _Recovered: + """What the caller still owes, given what already streamed. + + An unmatched tag is prose in a completed response and an unclosed side channel in a + truncated one, so `truncated` decides. A partial `` becomes reasoning; a + partial `` is unparseable and dropped. + """ + text = _without_closed_blocks(raw) + open_tag = _open_side_channel(text) if truncated else None + visible = open_tag.before if open_tag else text + tail = visible[len(emitted) :] if visible.startswith(emitted) else "" + if open_tag and open_tag.tag == "think" and open_tag.after: + return _Recovered(tail, open_tag.after) + return _Recovered(tail, None) + + +class ChatInterfaze(ChatOpenAI): + @classmethod + def is_lc_serializable(cls) -> bool: + return False + + @classmethod + def get_lc_namespace(cls) -> list[str]: + return ["interfaze_langchain", "chat_models"] + + @property + def lc_secrets(self) -> dict[str, str]: + return {"openai_api_key": "INTERFAZE_API_KEY"} + + # Provider family, not the model: `interfaze-beta` reaches tracing and the cache key + # via `ls_model_name` / `model_name`. Mirrors ChatOpenAI's "openai-chat". + @property + def _llm_type(self) -> str: + return "interfaze" + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + model: str | None = None, + show_additional_info: bool = False, + bypass_moa: bool = False, + bypass_cache: bool = False, + default_headers: dict[str, str] | None = None, + **kwargs: Any, + ) -> None: + """The three flags map to Interfaze control headers. `show_additional_info` is the + only way to get precontext while streaming; non-streaming responses always carry + it. `bypass_cache` matters when you need `reasoning`, which a cache hit omits. + """ + key = api_key or os.environ.get("INTERFAZE_API_KEY") + if not key: + raise InterfazeError( + "Missing API key. Pass ChatInterfaze(api_key=...) or set the INTERFAZE_API_KEY " + "environment variable." + ) + headers = {k.lower(): str(v) for k, v in (default_headers or {}).items()} + if show_additional_info: + headers[_HEADER_SHOW_ADDITIONAL_INFO] = "true" + if bypass_moa: + headers[_HEADER_BYPASS_MOA] = "true" + if bypass_cache: + headers[_HEADER_BYPASS_CACHE] = "true" + if "timeout" not in kwargs and "request_timeout" not in kwargs: + kwargs["timeout"] = _DEFAULT_TIMEOUT + kwargs.setdefault("stream_usage", True) + kwargs["use_responses_api"] = False + super().__init__( + api_key=SecretStr(key), + base_url=base_url or INTERFAZE_BASE_URL, + model=model or INTERFAZE_MODEL, + default_headers=headers or None, + **kwargs, + ) + + # Must be uniquely named: pydantic replaces same-named validators rather than chaining + # them, so reusing the parent's name would drop its version entry. + @model_validator(mode="after") + def _set_interfaze_version(self) -> Self: + self._add_version("interfaze-langchain", __version__) + return self + + @property + def _identifying_params(self) -> dict[str, Any]: + # Without these, set_llm_cache serves a bypass_cache model the plain model's answer, + # and one tenant's key the answer cached under another's. + params = {**super()._identifying_params, "_type": self._llm_type} + # A callable key resolves per request, so there is no stable value to key on. + if isinstance(self.openai_api_key, SecretStr): + params["interfaze_key"] = _digest(self.openai_api_key.get_secret_value()) + if self.default_headers: + params["interfaze_headers"] = _redact_headers(self.default_headers) + return params + + def _get_ls_params(self, stop: list[str] | None = None, **kwargs: Any) -> Any: + params = super()._get_ls_params(stop=stop, **kwargs) + params["ls_provider"] = _PROVIDER + return params + + def _get_request_payload( + self, + input_: LanguageModelInput, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + messages = self._convert_input(input_).to_messages() + patched = [ + m.model_copy(update={"content": _rewrite_video_blocks(m.content)}) + if isinstance(m.content, list) + else m + for m in messages + ] + payload = super()._get_request_payload(patched, stop=stop, **kwargs) + # Interfaze has no `reasoning` param; fold it into reasoning_effort. A per-call + # value always beats a model-level one. + payload.pop("reasoning", None) + effort = _first_effort( + kwargs.get("reasoning"), kwargs.get("reasoning_effort"), self.reasoning, self.reasoning_effort + ) + if effort is not None: + payload["reasoning_effort"] = effort + return payload + + def _create_chat_result( + self, + response: Any, + generation_info: dict[str, Any] | None = None, + ) -> ChatResult: + _default_role(response, "message") + result = super()._create_chat_result(response, generation_info) + response_dict = ( + response + if isinstance(response, dict) + else response.model_dump( + exclude={"choices": {"__all__": {"message": {"parsed"}}}}, warnings=False + ) + ) + side = _extract_side_fields(response_dict) + for generation in result.generations: + message = generation.message + if isinstance(message, AIMessage): + message.response_metadata["model_provider"] = _PROVIDER + _apply_side_fields(message, side) + _strip_tags(message, (generation.generation_info or {}).get("finish_reason") == "length") + # gen.text feeds callbacks and the serialized cache, so keep it in step + # with the stripped content or both carry the raw tags. + if isinstance(message.content, str): + generation.text = message.content + return result + + def _convert_chunk_to_generation_chunk( + self, + chunk: dict[str, Any], + default_chunk_class: type, + base_generation_info: dict[str, Any] | None, + ) -> ChatGenerationChunk | None: + # `with_structured_output` streams through beta.chat.completions, which nests the + # frame under "chunk" — side fields ride the envelope, so unwrap before reading. + body = chunk.get("chunk") or chunk + _default_role(body, "delta") + generation_chunk = super()._convert_chunk_to_generation_chunk( + chunk, default_chunk_class, base_generation_info + ) + if generation_chunk is None: + return generation_chunk + message = generation_chunk.message + if isinstance(message, AIMessage): + message.response_metadata["model_provider"] = _PROVIDER + side = _extract_side_fields(body) + if side: + _apply_side_fields(message, side) + return generation_chunk + + def _stream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + stream = _SideChannelStream() + # run_manager is withheld deliberately: ChatOpenAI fires on_llm_new_token before + # yielding, so handlers would see unfiltered ``. Fired below instead. + for gen in super()._stream(messages, stop=stop, run_manager=None, **kwargs): + stream.absorb(gen) + if run_manager: + run_manager.on_llm_new_token( + gen.text, chunk=gen, logprobs=(gen.generation_info or {}).get("logprobs") + ) + yield gen + final = stream.final_chunk() + if final is not None: + if run_manager: + run_manager.on_llm_new_token(final.text, chunk=final) + yield final + + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> AsyncIterator[ChatGenerationChunk]: + stream = _SideChannelStream() + # See _stream: the manager is withheld so handlers never see unfiltered ``. + async for gen in super()._astream(messages, stop=stop, run_manager=None, **kwargs): + stream.absorb(gen) + if run_manager: + await run_manager.on_llm_new_token( + gen.text, chunk=gen, logprobs=(gen.generation_info or {}).get("logprobs") + ) + yield gen + final = stream.final_chunk() + if final is not None: + if run_manager: + await run_manager.on_llm_new_token(final.text, chunk=final) + yield final + + +__all__ = ["ChatInterfaze"] diff --git a/python/langchain_interfaze/py.typed b/python/interfaze_langchain/py.typed similarity index 100% rename from python/langchain_interfaze/py.typed rename to python/interfaze_langchain/py.typed diff --git a/python/langchain_interfaze/__init__.py b/python/langchain_interfaze/__init__.py deleted file mode 100644 index c1a2e9c..0000000 --- a/python/langchain_interfaze/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from langchain_interfaze.chat_models import ChatInterfaze - -__all__ = ["ChatInterfaze"] diff --git a/python/langchain_interfaze/chat_models.py b/python/langchain_interfaze/chat_models.py deleted file mode 100644 index 5245a25..0000000 --- a/python/langchain_interfaze/chat_models.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -import os -from collections.abc import AsyncIterator, Iterator -from typing import Any - -from interfaze import ( - INTERFAZE_BASE_URL, - INTERFAZE_MODEL, - InterfazeError, - SideChannelFilter, - strip_side_channels, -) -from langchain_core.language_models import LanguageModelInput -from langchain_core.messages import AIMessage, AIMessageChunk -from langchain_core.outputs import ChatGenerationChunk, ChatResult -from langchain_openai import ChatOpenAI -from pydantic import Field, SecretStr - -_SIDE_FIELDS = ("precontext", "reasoning", "vcache") - - -def _extract_side_fields(data: dict[str, Any]) -> dict[str, Any]: - return {k: data[k] for k in _SIDE_FIELDS if data.get(k) is not None} - - -def _apply_side_fields(message: AIMessage, side: dict[str, Any]) -> None: - for key, value in side.items(): - message.response_metadata[key] = value - message.additional_kwargs[key] = value - - -def _strip_tags(message: AIMessage) -> None: - if not isinstance(message.content, str) or not ( - "" in message.content or "" in message.content - ): - return - text, reasoning, precontext = strip_side_channels(message.content) - if text != message.content: - message.content = text - if reasoning: - message.response_metadata.setdefault("reasoning", reasoning) - message.additional_kwargs.setdefault("reasoning", reasoning) - if precontext: - message.response_metadata.setdefault("precontext", precontext) - message.additional_kwargs.setdefault("precontext", precontext) - - -def _convert_video_block(block: dict[str, Any]) -> dict[str, Any]: - mime = block.get("mime_type") - if "url" in block: - file: dict[str, Any] = {"file_data": block["url"]} - elif "base64" in block: - mime = mime or "video/mp4" - file = {"file_data": f"data:{mime};base64,{block['base64']}"} - elif "file_id" in block: - file = {"file_id": block["file_id"]} - else: - raise InterfazeError("Video content block requires one of 'url', 'base64', or 'file_id'.") - if mime: - file["format"] = mime - extras = block.get("extras") - if isinstance(extras, dict) and extras.get("filename"): - file["filename"] = extras["filename"] - return {"type": "file", "file": file} - - -def _rewrite_video_blocks(content: Any) -> Any: - if not isinstance(content, list): - return content - rewritten = [ - _convert_video_block(block) if isinstance(block, dict) and block.get("type") == "video" else block - for block in content - ] - return rewritten if rewritten != content else content - - -def _filter_stream_chunk(gen: ChatGenerationChunk, filt: SideChannelFilter, raw: list[str]) -> None: - message = gen.message - if isinstance(message, AIMessage) and isinstance(message.content, str) and message.content: - raw.append(message.content) - message.content = filt.feed(message.content) - - -def _final_side_chunk(filt: SideChannelFilter, raw: list[str]) -> ChatGenerationChunk | None: - tail = filt.flush() - _, reasoning, precontext = strip_side_channels("".join(raw)) - if not tail and not reasoning and not precontext: - return None - message = AIMessageChunk(content=tail) - side: dict[str, Any] = {} - if reasoning: - side["reasoning"] = reasoning - if precontext: - side["precontext"] = precontext - _apply_side_fields(message, side) - return ChatGenerationChunk(message=message) - - -class ChatInterfaze(ChatOpenAI): - precontext: list[dict[str, Any]] | None = Field(default=None) - - @classmethod - def is_lc_serializable(cls) -> bool: - return False - - def __init__( - self, - *, - api_key: str | None = None, - base_url: str | None = None, - model: str | None = None, - **kwargs: Any, - ) -> None: - key = api_key or os.environ.get("INTERFAZE_API_KEY") - if not key: - raise InterfazeError( - "Missing API key. Pass ChatInterfaze(api_key=...) or set the INTERFAZE_API_KEY " - "environment variable." - ) - super().__init__( - api_key=SecretStr(key), - base_url=base_url or INTERFAZE_BASE_URL, - model=model or INTERFAZE_MODEL, - **kwargs, - ) - - def _get_request_payload( - self, - input_: LanguageModelInput, - *, - stop: list[str] | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - messages = self._convert_input(input_).to_messages() - patched = [ - m.model_copy(update={"content": _rewrite_video_blocks(m.content)}) - if isinstance(m.content, list) - else m - for m in messages - ] - payload = super()._get_request_payload(patched, stop=stop, **kwargs) - if self.precontext is not None: - extra_body = dict(payload.get("extra_body") or {}) - extra_body.setdefault("precontext", self.precontext) - payload["extra_body"] = extra_body - return payload - - def _create_chat_result( - self, - response: Any, - generation_info: dict[str, Any] | None = None, - ) -> ChatResult: - result = super()._create_chat_result(response, generation_info) - response_dict = ( - response - if isinstance(response, dict) - else response.model_dump( - exclude={"choices": {"__all__": {"message": {"parsed"}}}}, warnings=False - ) - ) - side = _extract_side_fields(response_dict) - for generation in result.generations: - message = generation.message - if isinstance(message, AIMessage): - _apply_side_fields(message, side) - _strip_tags(message) - return result - - def _convert_chunk_to_generation_chunk( - self, - chunk: dict[str, Any], - default_chunk_class: type, - base_generation_info: dict[str, Any] | None, - ) -> ChatGenerationChunk | None: - generation_chunk = super()._convert_chunk_to_generation_chunk( - chunk, default_chunk_class, base_generation_info - ) - if generation_chunk is None: - return generation_chunk - message = generation_chunk.message - if isinstance(message, AIMessage): - side = _extract_side_fields(chunk) - if side: - _apply_side_fields(message, side) - return generation_chunk - - def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGenerationChunk]: - filt = SideChannelFilter() - raw: list[str] = [] - for gen in super()._stream(*args, **kwargs): - _filter_stream_chunk(gen, filt, raw) - yield gen - final = _final_side_chunk(filt, raw) - if final is not None: - yield final - - async def _astream(self, *args: Any, **kwargs: Any) -> AsyncIterator[ChatGenerationChunk]: - filt = SideChannelFilter() - raw: list[str] = [] - async for gen in super()._astream(*args, **kwargs): - _filter_stream_chunk(gen, filt, raw) - yield gen - final = _final_side_chunk(filt, raw) - if final is not None: - yield final - - -__all__ = ["ChatInterfaze"] diff --git a/python/pyproject.toml b/python/pyproject.toml index 2d490b9..62096e5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -3,9 +3,10 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "langchain-interfaze" -version = "1.0.1" +name = "interfaze-langchain" +version = "1.0.0" description = "Interfaze Langchain SDK" +readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } authors = [{ name = "InterfazeAI" }] @@ -16,8 +17,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "interfaze>=1.0.2,<2", - "langchain-openai>=1.4.1,<1.5", + "interfaze>=1.0.3,<2", + "langchain-openai>=1.4.1,<2", "langchain-core>=1.0,<2", "pydantic>=2,<3", ] @@ -40,12 +41,19 @@ lint = ["ruff==0.16.0"] typing = ["mypy==2.3.0"] [tool.hatch.build.targets.wheel] -packages = ["langchain_interfaze"] +packages = ["interfaze_langchain"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests/unit_tests"] -addopts = "--cov=langchain_interfaze --cov-report=term-missing --cov-fail-under=95" +addopts = "" +filterwarnings = [ + "error", + # Matched on module, not message: langchain-openai hands pydantic a `parsed` field it + # types as None, and gating ci on the wording of someone else's warning means a reword + # upstream turns into a red build here. + "ignore::UserWarning:pydantic.main", +] [tool.ruff] line-length = 110 @@ -53,13 +61,13 @@ line-length = 110 [tool.mypy] python_version = "3.12" strict = true -files = ["langchain_interfaze"] +files = ["interfaze_langchain", "scripts"] [[tool.mypy.overrides]] module = ["langchain_openai.*", "langchain_core.*"] ignore_missing_imports = true [[tool.mypy.overrides]] -module = ["langchain_interfaze.chat_models"] +module = ["interfaze_langchain.chat_models"] disallow_subclassing_any = false warn_return_any = false diff --git a/python/scripts/qa_live.py b/python/scripts/qa_live.py new file mode 100644 index 0000000..fbcb624 --- /dev/null +++ b/python/scripts/qa_live.py @@ -0,0 +1,388 @@ +"""Live QA — exercises ChatInterfaze against real Interfaze (go/no-go gate; not CI). + +Run: INTERFAZE_API_KEY=... uv run python scripts/qa_live.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from collections.abc import Callable +from typing import Any + +from interfaze import BadRequestError, InterfazeError +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from interfaze_langchain import ChatInterfaze + + +def load_key() -> str: + key = os.environ.get("INTERFAZE_API_KEY") + if not key: + raise SystemExit("Set INTERFAZE_API_KEY to run the live QA.") + return key + + +def make_llm(**kwargs: Any) -> ChatInterfaze: + base_url = os.environ.get("INTERFAZE_BASE_URL") + if base_url: + kwargs.setdefault("base_url", base_url) + kwargs.setdefault("timeout", 180.0) + return ChatInterfaze(api_key=load_key(), max_retries=1, **kwargs) + + +llm = make_llm() + +# The semantic cache replays a stored answer with no `reasoning` attached. +fresh = make_llm(bypass_cache=True) + +ASSETS = { + "receipt": "https://jigsawstack.com/preview/vocr-example.jpg", + "id": "https://r2public.jigsawstack.com/interfaze/examples/id.jpg", + "audio": "https://jigsawstack.com/preview/stt-example.wav", + "video": "https://download.samplelib.com/mp4/sample-5s.mp4", + "csv": "https://r2public.jigsawstack.com/interfaze/examples/prediction-example.csv", + "pdf": "https://arxiv.org/pdf/1706.03762", + "docx": "https://calibre-ebook.com/downloads/demos/demo.docx", + "scene": "https://ultralytics.com/images/bus.jpg", +} +failures: list[str] = [] + + +def check(name: str, run: Callable[[], str]) -> None: + try: + print(f" PASS {name} — {run()}") + except Exception as e: # noqa: BLE001 + print(f" FAIL {name} — {type(e).__name__}: {e}") + failures.append(name) + + +def _assert(cond: Any, msg: str) -> None: + if not cond: + raise AssertionError(msg) + + +def ask(prompt: str, part: dict[str, Any]) -> HumanMessage: + return HumanMessage(content=[{"type": "text", "text": prompt}, part]) + + +def image(url: str) -> dict[str, Any]: + return {"type": "image_url", "image_url": {"url": url}} + + +def file(url: str, filename: str | None = None) -> dict[str, Any]: + f: dict[str, Any] = {"file_data": url} + if filename: + f["filename"] = filename + return {"type": "file", "file": f} + + +def names(message: AIMessage) -> list[str]: + """Names of the internal tools Interfaze ran, from `response_metadata.precontext`.""" + entries = message.response_metadata.get("precontext") or [] + return [p["name"] for p in entries if isinstance(p, dict) and p.get("name")] + + +def text_generation() -> str: + res = llm.invoke("Say hi in one short sentence.") + _assert(res.content, "empty") + _assert(isinstance(res.response_metadata.get("vcache"), bool), "no vcache") + return f"vcache={res.response_metadata['vcache']}" + + +def provider_identity() -> str: + res = llm.invoke("Say hi.") + _assert(res.response_metadata.get("model_provider") == "interfaze", "wrong model_provider") + return "model_provider=interfaze" + + +def token_usage() -> str: + res = llm.invoke("Say hi.") + u = res.usage_metadata + if u is None: + raise AssertionError("no usage_metadata") + _assert(u["input_tokens"] > 0 and u["output_tokens"] > 0, "zero token counts") + return f"in={u['input_tokens']} out={u['output_tokens']}" + + +def streaming() -> str: + """Reasoning is requested so the wire actually carries ``. + + Against a prompt that produces no tags the leak assertion below cannot fail, which is + how this check passed while the filter was broken for role-less deltas. + """ + chunks = list(fresh.stream("Why is the sky blue? Briefly.", reasoning_effort="high")) + text = "".join(str(c.content) for c in chunks) + _assert(chunks and text, "empty stream") + _assert("" not in text and "" not in text, "side-channel tags leaked") + reasoning = [c for c in chunks if c.response_metadata.get("reasoning")] + _assert(reasoning, "no reasoning produced — a tag leak would be undetectable here") + return f"{len(chunks)} chunks, reasoning stripped out" + + +def streaming_usage() -> str: + """`stream_usage=True` is forced on; langchain-openai omits it for non-OpenAI base URLs.""" + total = 0 + for chunk in llm.stream("Say hi."): + if chunk.usage_metadata: + total = chunk.usage_metadata["total_tokens"] + _assert(total > 0, "no usage on stream") + return f"total={total}" + + +class Greeting(BaseModel): + greeting: str + count: int + + +def structured_output() -> str: + out = llm.with_structured_output(Greeting).invoke("Give a greeting and the number 3.") + if not isinstance(out, Greeting): + raise TypeError(f"not a Greeting: {out!r}") + _assert(out.greeting, "fields missing") + return f"{out.greeting!r}/{out.count}" + + +@tool +def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"Sunny in {city}" + + +def tool_calling() -> str: + res = llm.bind_tools([get_weather]).invoke("Weather in Paris? Use the tool.") + _assert(res.tool_calls, "no tool_calls") + return f"{len(res.tool_calls)} call(s)" + + +def reasoning() -> str: + res = fresh.invoke("Why is the sky blue? Briefly.", reasoning_effort="high") + _assert(res.response_metadata.get("reasoning"), "no reasoning parsed") + _assert("" not in str(res.content), "think tag leaked into content") + return f"reasoning {len(res.response_metadata['reasoning'])} chars" + + +def reasoning_widened() -> str: + """Interfaze accepts `on` / `off` / `auto` on top of the OpenAI enum.""" + res = make_llm(reasoning_effort="on").invoke("Hello") + _assert(res.content, "empty") + return "accepted 'on'" + + +def precontext() -> str: + res = llm.invoke([ask("Extract the total price.", file(ASSETS["receipt"]))]) + _assert(names(res), "no precontext") + return f"names={names(res)}" + + +def router_picks_a_tool_unprompted() -> str: + res = fresh.invoke("Which US public companies reported earnings today?") + _assert(res.content, "empty") + _assert(names(res), "router ran no tool; the README says a web search backs this answer") + return f"names={names(res)}" + + +def streamed_precontext() -> str: + """`show_additional_info` is the only way to get precontext while streaming.""" + got: list[Any] = [] + visible: list[str] = [] + for chunk in make_llm(show_additional_info=True, bypass_cache=True).stream( + [ask("Extract the total price.", file(ASSETS["receipt"]))] + ): + if isinstance(chunk.content, str): + visible.append(chunk.content) + if chunk.response_metadata.get("precontext"): + got.append(chunk.response_metadata["precontext"]) + _assert(got, "no streamed precontext") + _assert(len(got) == 1, f"precontext emitted {len(got)}x; should be deduped to 1") + _assert("" not in "".join(visible), "raw leaked into visible text") + return "1 precontext chunk" + + +def guardrails() -> str: + codes = ", ".join(f"S{i}" for i in range(1, 15)) + unsafe = llm.invoke([SystemMessage(f"{codes}"), HumanMessage("How to kill a human?")]) + _assert("unsafe" in str(unsafe.content).lower(), "not flagged") + benign = llm.invoke( + [SystemMessage(f"{codes}"), HumanMessage("What is the capital of France?")] + ) + _assert("unsafe" not in str(benign.content).lower(), "benign prompt flagged unsafe") + _assert("paris" in str(benign.content).lower(), "benign prompt blocked") + return "unsafe flagged, benign passed" + + +def task_tag() -> str: + res = llm.invoke([SystemMessage("web_search"), HumanMessage("GLP-1 research paper")]) + _assert(res.content, "empty") + return "web_search ran" + + +def chain_lcel() -> str: + chain = ChatPromptTemplate.from_template("Translate to {lang}: {text}") | llm + res = chain.invoke({"lang": "French", "text": "Hello"}) + _assert(res.content, "empty") + return "ok" + + +def batch() -> str: + out = llm.batch(["Say A.", "Say B."]) + _assert(all(r.content for r in out), "empty batch result") + return f"{len(out)} results" + + +def async_smoke() -> str: + async def go() -> str: + res = await llm.ainvoke("Say hi.") + _assert(res.content, "ainvoke empty") + n = 0 + async for _ in llm.astream("Count 1 to 3."): + n += 1 + _assert(n > 0, "no async chunks") + return f"ainvoke + {n} astream chunks" + + return asyncio.run(go()) + + +def rejects(name: str, detail: str, run: Callable[[], Any]) -> None: + """Assert the server refuses a request, optionally matching text in the 400.""" + + def fn() -> str: + try: + run() + except BadRequestError as e: + _assert(not detail or detail in str(e).lower(), str(e)) + return "400" + raise AssertionError(f"{name}: the request was accepted") + + check(name, fn) + + +def rejects_video_file_id() -> str: + try: + llm.invoke([ask("what is this?", {"type": "video", "file_id": "file-123"})]) + except InterfazeError as e: + _assert("file_id" in str(e), str(e)) + return "InterfazeError" + raise AssertionError("file_id was accepted") + + +async def _astream_events() -> str: + reasoning_llm = make_llm(bypass_cache=True, reasoning_effort="high") + body = "" + end: Any = None + async for ev in reasoning_llm.astream_events("Why is the sky blue? Briefly.", version="v2"): + if ev["event"] == "on_chat_model_stream": + content = ev["data"]["chunk"].content + if isinstance(content, str): + body += content + elif ev["event"] == "on_chat_model_end": + end = ev["data"]["output"] + _assert(body, "no events") + _assert("" not in body, "think tag leaked into astream_events") + _assert(end is not None, "no on_chat_model_end event") + # The js gate asserts the same two fields on message-finish; without them a stream that + # silently reports the wrong finish reason still passes. + _assert( + end.response_metadata.get("finish_reason") == "stop", + f"finish_reason {end.response_metadata.get('finish_reason')}", + ) + _assert( + end.response_metadata.get("model_provider") == "interfaze", "no model_provider on the terminal event" + ) + saw = any( + c.response_metadata.get("reasoning") for c in reasoning_llm.stream("Why is the sky blue? Briefly.") + ) + _assert(saw, "no reasoning produced — a leak would be undetectable here") + return f"{len(body)} chars, finish_reason + reasoning confirmed" + + +def astream_events() -> str: + return asyncio.run(_astream_events()) + + +def input_check(label: str, make_part: Callable[[], dict[str, Any]], prompt: str) -> None: + def fn() -> str: + res = llm.invoke([ask(prompt, make_part())]) + _assert(res.content, "empty") + return "ok" + + check(f"input: {label}", fn) + + +class Bill(BaseModel): + vendor_name: str + total_amount: float = Field(description="Grand total") + + +def ocr_structured() -> str: + out = llm.with_structured_output(Bill).invoke([ask("Extract the receipt.", image(ASSETS["receipt"]))]) + if not isinstance(out, Bill): + raise TypeError(f"not a Bill: {out!r}") + _assert(out.vendor_name and out.total_amount > 0, "fields missing") + return f"{out.vendor_name!r}/{out.total_amount}" + + +check("text generation", text_generation) +check("provider identity", provider_identity) +check("token usage", token_usage) +check("streaming (tags stripped)", streaming) +check("streaming usage metadata", streaming_usage) +check("structured output", structured_output) +check("tool calling", tool_calling) +check("reasoning + ", reasoning) +check("reasoning_effort 'on'", reasoning_widened) +check("precontext (auto path)", precontext) +check("router picks a tool unprompted", router_picks_a_tool_unprompted) +check("streamed precontext (deduped)", streamed_precontext) +check("ocr -> structured output", ocr_structured) +check("guardrails -> unsafe", guardrails) +check(" system message", task_tag) +check("chain (LCEL)", chain_lcel) +check("batch", batch) +check("async (ainvoke + astream)", async_smoke) + +check("astream_events (tags stripped)", astream_events) +rejects("rejects temperature > 1", "", lambda: make_llm(temperature=1.5).invoke("hi")) +rejects( + "rejects multiple tags", + "only one task", + lambda: llm.invoke([SystemMessage("ocr, web_search"), HumanMessage("hi")]), +) +rejects( + "rejects an invalid task", + "invalid task", + lambda: llm.invoke([SystemMessage("foobar_tool"), HumanMessage("hi")]), +) +rejects("rejects an empty message", "", lambda: llm.invoke([HumanMessage("")])) +rejects( + "rejects malformed base64", + "", + lambda: llm.invoke([ask("what is this?", image("data:image/jpeg;base64,@@@@not-valid@@@@===="))]), +) +# Not a `rejects` case: this one never reaches the server. +check("rejects a video file_id client-side", rejects_video_file_id) + +input_check("image url", lambda: image(ASSETS["id"]), "What kind of document is this?") +input_check("pdf url", lambda: file(ASSETS["pdf"], "paper.pdf"), "Give the title.") +input_check("docx url", lambda: file(ASSETS["docx"], "demo.docx"), "What is this document about?") +input_check("audio url", lambda: file(ASSETS["audio"], "stt-example.wav"), "Transcribe this.") +input_check("video block", lambda: {"type": "video", "url": ASSETS["video"]}, "Describe this video.") +input_check("csv url", lambda: file(ASSETS["csv"], "data.csv"), "Name one column header.") + + +def inline_url() -> str: + res = llm.invoke(f"Extract the total from this receipt: {ASSETS['receipt']}") + _assert(res.content, "empty") + return "ok" + + +check("input: inline URL", inline_url) + +print( + f"\nLIVE QA: {'ALL PASSED (go)' if not failures else f'{len(failures)} FAILED (no-go): ' + ', '.join(failures)}" +) +sys.exit(1 if failures else 0) diff --git a/python/tests/integration_tests/test_chat_models.py b/python/tests/integration_tests/test_chat_models.py index 4a02dd0..81c8523 100644 --- a/python/tests/integration_tests/test_chat_models.py +++ b/python/tests/integration_tests/test_chat_models.py @@ -1,10 +1,13 @@ from __future__ import annotations +import os from typing import Any +import pytest +from langchain_core.language_models import BaseChatModel from langchain_tests.integration_tests import ChatModelIntegrationTests -from langchain_interfaze import ChatInterfaze +from interfaze_langchain import ChatInterfaze class TestChatInterfazeIntegration(ChatModelIntegrationTests): @@ -14,4 +17,85 @@ def chat_model_class(self) -> type[ChatInterfaze]: @property def chat_model_params(self) -> dict[str, Any]: - return {"model": "interfaze-beta"} + params: dict[str, Any] = {"model": "interfaze-beta"} + base_url = os.environ.get("INTERFAZE_BASE_URL") + if base_url: + params["base_url"] = base_url + return params + + @property + def has_tool_calling(self) -> bool: + return True + + @property + def has_tool_choice(self) -> bool: + # Interfaze accepts `tools` but drops `tool_choice`; the router always decides. + return False + + @property + def has_structured_output(self) -> bool: + return True + + @property + def supports_json_mode(self) -> bool: + return True + + @property + def supports_image_inputs(self) -> bool: + return True + + @property + def supports_image_urls(self) -> bool: + return True + + @property + def supports_pdf_inputs(self) -> bool: + return True + + @property + def supports_audio_inputs(self) -> bool: + return True + + @property + def supports_video_inputs(self) -> bool: + return True + + @property + def supports_image_tool_message(self) -> bool: + # Interfaze rejects assistant/tool messages carrying image content blocks. + return False + + @property + def supports_pdf_tool_message(self) -> bool: + return False + + @property + def supports_anthropic_inputs(self) -> bool: + return False + + @property + def returns_usage_metadata(self) -> bool: + return True + + @pytest.mark.xfail( + reason="Interfaze rejects assistant messages whose content is a list of blocks " + "(400 invalid_request on messages.N); only string content is accepted there." + ) + def test_tool_message_histories_list_content(self, model: BaseChatModel, my_adder_tool: Any) -> None: + super().test_tool_message_histories_list_content(model, my_adder_tool) + + @pytest.mark.xfail( + reason="Interfaze drops `tool_choice` and routes tool use itself, so binding a " + "runnable as a tool does not reliably produce a tool call.", + strict=False, + ) + def test_bind_runnables_as_tools(self, model: BaseChatModel) -> None: + super().test_bind_runnables_as_tools(model) + + @pytest.mark.xfail( + reason="Interfaze drops `tool_choice` and routes tool use itself, so a user tool " + "the model can answer without (here: the weather) is not reliably called.", + strict=False, + ) + def test_agent_loop(self, model: BaseChatModel) -> None: + super().test_agent_loop(model) diff --git a/python/tests/unit_tests/conftest.py b/python/tests/unit_tests/conftest.py new file mode 100644 index 0000000..5238447 --- /dev/null +++ b/python/tests/unit_tests/conftest.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import functools +import json +import operator +from typing import Any + +import httpx +import respx + +CHAT_URL = "https://api.interfaze.ai/v1/chat/completions" +VIDEO_URL = "https://download.samplelib.com/mp4/sample-5s.mp4" + +_USAGE = {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8} + + +def completion(content: Any = "Hi!", *, finish_reason: str = "stop", **extra: Any) -> dict[str, Any]: + body: dict[str, Any] = { + "id": "req-test", + "object": "chat.completion", + "created": 1_700_000_000, + "model": "interfaze-beta", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content, "refusal": None}, + "finish_reason": finish_reason, + "logprobs": None, + } + ], + "usage": _USAGE, + "vcache": False, + } + body.update(extra) + return body + + +def chunk(delta: dict[str, Any], finish_reason: str | None = None) -> dict[str, Any]: + return { + "id": "req-test", + "object": "chat.completion.chunk", + "created": 1_700_000_000, + "model": "interfaze-beta", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}], + } + + +def _sse_bytes(chunks: list[dict[str, Any]]) -> bytes: + return ("".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n").encode() + + +def mock_json(body: dict[str, Any]) -> respx.Route: + return respx.post(CHAT_URL).mock(return_value=httpx.Response(200, json=body)) + + +def mock_sse(chunks: list[dict[str, Any]]) -> respx.Route: + return respx.post(CHAT_URL).mock( + return_value=httpx.Response( + 200, headers={"content-type": "text/event-stream"}, content=_sse_bytes(chunks) + ) + ) + + +def last_body(route: respx.Route) -> dict[str, Any]: + return json.loads(route.calls.last.request.content) + + +BASIC = completion("Hi!") +CUSTOM_FIELDS = completion( + "Hello there", + precontext=[{"name": "ocr", "result": {"extracted_text": "x"}}], + reasoning="because reasons", + vcache=True, +) +INLINE_TAGS = completion( + "Rayleigh scattering." + '[{"name": "ocr", "result": {"x": 1}}]' + "The sky is blue." +) +STREAM_CHUNKS: list[dict[str, Any]] = [ + chunk({"content": '[{"name":"ocr","result":{"extracted_text":"x"}}]'}), + chunk({"content": "Total "}), + chunk({"content": "is $12.34"}), + chunk({}, finish_reason="stop"), +] +THINK_SPLIT: list[dict[str, Any]] = [ + chunk({"content": "Rayleigh scat"}), + chunk({"content": "tering.The sky "}), + chunk({"content": "is blue."}), + chunk({}, finish_reason="stop"), +] +PLAIN_STREAM: list[dict[str, Any]] = [ + chunk({"content": "Hello "}), + chunk({"content": "world"}), + chunk({}, finish_reason="stop"), +] +# The same side field on consecutive chunks: `reasoning` would string-concatenate and +# `precontext` would append on merge; `vcache` merges cleanly. +REPEATED_SIDE: list[dict[str, Any]] = [ + chunk({"content": "a"}) | {"reasoning": "why", "precontext": [{"name": "ocr"}], "vcache": True}, + chunk({"content": "b"}) | {"reasoning": "why", "precontext": [{"name": "ocr"}], "vcache": True}, + chunk({}, finish_reason="stop"), +] + + +def merge(chunks: list[Any]) -> Any: + return functools.reduce(operator.add, chunks) diff --git a/python/tests/unit_tests/test_chat.py b/python/tests/unit_tests/test_chat.py new file mode 100644 index 0000000..811f15d --- /dev/null +++ b/python/tests/unit_tests/test_chat.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import respx +from langchain_core.messages import HumanMessage + +from interfaze_langchain import ChatInterfaze +from tests.unit_tests.conftest import BASIC, CUSTOM_FIELDS, INLINE_TAGS, mock_json + + +@respx.mock +def test_custom_response_fields_surfaced() -> None: + mock_json(CUSTOM_FIELDS) + model = ChatInterfaze(api_key="t") + result = model.invoke([HumanMessage("hi")]) + assert result.response_metadata["precontext"] == [{"name": "ocr", "result": {"extracted_text": "x"}}] + assert result.response_metadata["reasoning"] == "because reasons" + assert result.response_metadata["vcache"] is True + assert result.additional_kwargs["precontext"] == [{"name": "ocr", "result": {"extracted_text": "x"}}] + assert result.additional_kwargs["reasoning"] == "because reasons" + assert result.additional_kwargs["vcache"] is True + + +@respx.mock +def test_response_without_precontext_or_reasoning_unaffected() -> None: + mock_json(BASIC) + model = ChatInterfaze(api_key="t") + result = model.invoke([HumanMessage("hi")]) + assert "precontext" not in result.response_metadata + assert "reasoning" not in result.response_metadata + assert result.response_metadata["vcache"] is False + assert result.content == "Hi!" + + +@respx.mock +def test_non_streaming_strips_inline_tags() -> None: + mock_json(INLINE_TAGS) + model = ChatInterfaze(api_key="t") + result = model.invoke([HumanMessage("why is the sky blue?")]) + assert result.content == "The sky is blue." + assert result.response_metadata["reasoning"] == "Rayleigh scattering." + assert result.response_metadata["precontext"] == [{"name": "ocr", "result": {"x": 1}}] + + +@respx.mock +def test_async_invoke_surfaces_side_fields() -> None: + mock_json(CUSTOM_FIELDS) + model = ChatInterfaze(api_key="t") + + async def go() -> Any: + return await model.ainvoke([HumanMessage("hi")]) + + result = asyncio.run(go()) + assert result.response_metadata["precontext"][0]["name"] == "ocr" + assert result.response_metadata["vcache"] is True diff --git a/python/tests/unit_tests/test_chat_models.py b/python/tests/unit_tests/test_chat_models.py deleted file mode 100644 index 1caf360..0000000 --- a/python/tests/unit_tests/test_chat_models.py +++ /dev/null @@ -1,299 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from typing import Any - -import httpx -import pytest -import respx -from interfaze import INTERFAZE_BASE_URL, INTERFAZE_MODEL, InterfazeError -from langchain_core.messages import HumanMessage - -from langchain_interfaze import ChatInterfaze - -CHAT_URL = "https://api.interfaze.ai/v1/chat/completions" -VIDEO_URL = "https://download.samplelib.com/mp4/sample-5s.mp4" - -_USAGE = {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8} - - -def completion(content: Any = "Hi!", *, finish_reason: str = "stop", **extra: Any) -> dict[str, Any]: - body: dict[str, Any] = { - "id": "req-test", - "object": "chat.completion", - "created": 1_700_000_000, - "model": "interfaze-beta", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": content, "refusal": None}, - "finish_reason": finish_reason, - "logprobs": None, - } - ], - "usage": _USAGE, - "vcache": False, - } - body.update(extra) - return body - - -def _chunk(delta: dict[str, Any], finish_reason: str | None = None) -> dict[str, Any]: - return { - "id": "req-test", - "object": "chat.completion.chunk", - "created": 1_700_000_000, - "model": "interfaze-beta", - "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}], - } - - -def _sse_bytes(chunks: list[dict[str, Any]]) -> bytes: - return ("".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n").encode() - - -def mock_json(body: dict[str, Any]) -> respx.Route: - return respx.post(CHAT_URL).mock(return_value=httpx.Response(200, json=body)) - - -def mock_sse(chunks: list[dict[str, Any]]) -> respx.Route: - return respx.post(CHAT_URL).mock( - return_value=httpx.Response( - 200, headers={"content-type": "text/event-stream"}, content=_sse_bytes(chunks) - ) - ) - - -def last_body(route: respx.Route) -> dict[str, Any]: - return json.loads(route.calls.last.request.content) - - -BASIC = completion("Hi!") -CUSTOM_FIELDS = completion( - "Hello there", - precontext=[{"name": "ocr", "result": {"extracted_text": "x"}}], - reasoning="because reasons", - vcache=True, -) -STREAM_CHUNKS: list[dict[str, Any]] = [ - _chunk({"content": '[{"name":"ocr","result":{"extracted_text":"x"}}]'}), - _chunk({"content": "Total "}), - _chunk({"content": "is $12.34"}), - _chunk({}, finish_reason="stop"), -] -THINK_SPLIT: list[dict[str, Any]] = [ - _chunk({"content": "Rayleigh scat"}), - _chunk({"content": "tering.The sky "}), - _chunk({"content": "is blue."}), - _chunk({}, finish_reason="stop"), -] - - -# defaults -def test_defaults_point_at_interfaze() -> None: - model = ChatInterfaze(api_key="t") - assert model.openai_api_base == INTERFAZE_BASE_URL - assert model.model_name == INTERFAZE_MODEL - - -def test_defaults_overridable() -> None: - model = ChatInterfaze(api_key="t", base_url="https://example.com/v1", model="other-model") - assert model.openai_api_base == "https://example.com/v1" - assert model.model_name == "other-model" - - -def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("INTERFAZE_API_KEY", raising=False) - with pytest.raises(InterfazeError, match="Missing API key"): - ChatInterfaze() - - -def test_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("INTERFAZE_API_KEY", "env-key") - model = ChatInterfaze() - assert model.openai_api_key is not None - - -# custom response fields -@respx.mock -def test_custom_response_fields_surfaced() -> None: - mock_json(CUSTOM_FIELDS) - model = ChatInterfaze(api_key="t") - result = model.invoke([HumanMessage("hi")]) - assert result.response_metadata["precontext"] == [{"name": "ocr", "result": {"extracted_text": "x"}}] - assert result.response_metadata["reasoning"] == "because reasons" - assert result.response_metadata["vcache"] is True - assert result.additional_kwargs["precontext"] == [{"name": "ocr", "result": {"extracted_text": "x"}}] - assert result.additional_kwargs["reasoning"] == "because reasons" - assert result.additional_kwargs["vcache"] is True - - -@respx.mock -def test_response_without_precontext_or_reasoning_unaffected() -> None: - mock_json(BASIC) - model = ChatInterfaze(api_key="t") - result = model.invoke([HumanMessage("hi")]) - assert "precontext" not in result.response_metadata - assert "reasoning" not in result.response_metadata - assert result.response_metadata["vcache"] is False - assert result.content == "Hi!" - - -# request-side precontext -@respx.mock -def test_request_precontext_injected() -> None: - route = mock_json(BASIC) - model = ChatInterfaze(api_key="t", precontext=[{"name": "ocr", "result": {"extracted_text": "y"}}]) - model.invoke([HumanMessage("hi")]) - body = last_body(route) - assert body["precontext"] == [{"name": "ocr", "result": {"extracted_text": "y"}}] - - -@respx.mock -def test_request_without_precontext_field_omits_it() -> None: - route = mock_json(BASIC) - model = ChatInterfaze(api_key="t") - model.invoke([HumanMessage("hi")]) - body = last_body(route) - assert "precontext" not in body - - -# video content blocks -@respx.mock -def test_video_block_converted_to_file_part() -> None: - route = mock_json(BASIC) - model = ChatInterfaze(api_key="t") - message = HumanMessage( - content=[ - {"type": "text", "text": "what happens in this clip?"}, - {"type": "video", "url": VIDEO_URL}, - ] - ) - model.invoke([message]) # must not raise - body = last_body(route) - content = body["messages"][-1]["content"] - assert {"type": "file", "file": {"file_data": VIDEO_URL}} in content - - -@respx.mock -def test_video_block_base64_converted_to_file_part() -> None: - route = mock_json(BASIC) - model = ChatInterfaze(api_key="t") - message = HumanMessage(content=[{"type": "video", "base64": "AAAA", "mime_type": "video/mp4"}]) - model.invoke([message]) - body = last_body(route) - content = body["messages"][-1]["content"] - assert content[0]["type"] == "file" - assert content[0]["file"]["file_data"] == "data:video/mp4;base64,AAAA" - - -# inline tag stripping (streaming) -@respx.mock -def test_streaming_strips_inline_tags_and_carries_precontext() -> None: - mock_sse(STREAM_CHUNKS) - model = ChatInterfaze(api_key="t") - chunks = list(model.stream([HumanMessage("x")])) - text = "".join(c.content for c in chunks) # ty:ignore[no-matching-overload] - assert "" not in text - assert text == "Total is $12.34" - precontext_chunks = [c for c in chunks if c.additional_kwargs.get("precontext")] - assert precontext_chunks - assert precontext_chunks[0].additional_kwargs["precontext"][0]["name"] == "ocr" - - -@respx.mock -def test_streaming_recovers_reasoning_split_across_chunks() -> None: - mock_sse(THINK_SPLIT) - model = ChatInterfaze(api_key="t") - chunks = list(model.stream([HumanMessage("x")])) - text = "".join(c.content for c in chunks) # ty:ignore[no-matching-overload] - assert "" not in text and text == "The sky is blue." - reasoning = [c for c in chunks if c.additional_kwargs.get("reasoning")] - assert reasoning and reasoning[0].additional_kwargs["reasoning"] == "Rayleigh scattering." - - -@respx.mock -def test_async_streaming_recovers_reasoning_split_across_chunks() -> None: - mock_sse(THINK_SPLIT) - model = ChatInterfaze(api_key="t") - - async def go() -> list[Any]: - return [c async for c in model.astream([HumanMessage("x")])] - - chunks = asyncio.run(go()) - text = "".join(c.content for c in chunks) - assert "" not in text and text == "The sky is blue." - reasoning = [c for c in chunks if c.additional_kwargs.get("reasoning")] - assert reasoning and reasoning[0].additional_kwargs["reasoning"] == "Rayleigh scattering." - - -# async -@respx.mock -def test_async_invoke_surfaces_side_fields() -> None: - mock_json(CUSTOM_FIELDS) - model = ChatInterfaze(api_key="t") - - async def go() -> Any: - return await model.ainvoke([HumanMessage("hi")]) - - result = asyncio.run(go()) - assert result.response_metadata["precontext"][0]["name"] == "ocr" - assert result.response_metadata["vcache"] is True - - -# inline tag stripping (non-streaming) -@respx.mock -def test_non_streaming_strips_inline_tags() -> None: - content = ( - "Rayleigh scattering." - '[{"name": "ocr", "result": {"x": 1}}]' - "The sky is blue." - ) - mock_json(completion(content)) - model = ChatInterfaze(api_key="t") - result = model.invoke([HumanMessage("why is the sky blue?")]) - assert result.content == "The sky is blue." - assert result.response_metadata["reasoning"] == "Rayleigh scattering." - assert result.response_metadata["precontext"] == [{"name": "ocr", "result": {"x": 1}}] - - -# more video content blocks -@respx.mock -def test_video_block_file_id() -> None: - route = mock_json(BASIC) - model = ChatInterfaze(api_key="t") - model.invoke([HumanMessage(content=[{"type": "video", "file_id": "file-123"}])]) - content = last_body(route)["messages"][-1]["content"] - assert content[0] == {"type": "file", "file": {"file_id": "file-123"}} - - -@respx.mock -def test_video_block_forwards_filename() -> None: - route = mock_json(BASIC) - model = ChatInterfaze(api_key="t") - model.invoke( - [HumanMessage(content=[{"type": "video", "url": VIDEO_URL, "extras": {"filename": "clip.mp4"}}])] - ) - file = last_body(route)["messages"][-1]["content"][0]["file"] - assert file["file_data"] == VIDEO_URL - assert file["filename"] == "clip.mp4" - - -def test_video_block_missing_source_raises() -> None: - model = ChatInterfaze(api_key="t") - with pytest.raises(InterfazeError, match="requires one of"): - model.invoke([HumanMessage(content=[{"type": "video"}])]) - - -# streaming with no side channels -@respx.mock -def test_streaming_plain_content_emits_no_side_channel_chunk() -> None: - mock_sse([_chunk({"content": "Hello "}), _chunk({"content": "world"}), _chunk({}, finish_reason="stop")]) - model = ChatInterfaze(api_key="t") - chunks = list(model.stream([HumanMessage("hi")])) - assert "".join(c.content for c in chunks) == "Hello world" # ty:ignore[no-matching-overload] - assert not any( - c.additional_kwargs.get("precontext") or c.additional_kwargs.get("reasoning") for c in chunks - ) diff --git a/python/tests/unit_tests/test_client.py b/python/tests/unit_tests/test_client.py new file mode 100644 index 0000000..63e80cf --- /dev/null +++ b/python/tests/unit_tests/test_client.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import Any + +import pytest +import respx +from interfaze import INTERFAZE_BASE_URL, INTERFAZE_MODEL, InterfazeError +from langchain_core.messages import HumanMessage + +from interfaze_langchain import ChatInterfaze +from tests.unit_tests.conftest import BASIC, chunk, last_body, mock_json, mock_sse + + +def test_defaults_point_at_interfaze() -> None: + model = ChatInterfaze(api_key="t") + assert model.openai_api_base == INTERFAZE_BASE_URL + assert model.model_name == INTERFAZE_MODEL + + +def test_defaults_overridable() -> None: + model = ChatInterfaze(api_key="t", base_url="https://example.com/v1", model="other-model") + assert model.openai_api_base == "https://example.com/v1" + assert model.model_name == "other-model" + + +def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("INTERFAZE_API_KEY", raising=False) + with pytest.raises(InterfazeError, match="Missing API key"): + ChatInterfaze() + + +def test_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INTERFAZE_API_KEY", "env-key") + model = ChatInterfaze() + assert model.openai_api_key is not None + + +def test_defaults_to_long_timeout_but_respects_override() -> None: + assert ChatInterfaze(api_key="t").request_timeout == 900.0 + assert ChatInterfaze(api_key="t", timeout=30).request_timeout == 30 + + +def test_never_routes_to_the_responses_api() -> None: + # `reasoning=` would otherwise flip ChatOpenAI over to /v1/responses. + assert ChatInterfaze(api_key="t", reasoning={"summary": "auto"}).use_responses_api is False + + +@respx.mock +def test_reasoning_kwarg_is_folded_into_reasoning_effort() -> None: + route = mock_json(BASIC) + model = ChatInterfaze(api_key="t", reasoning={"effort": "high", "summary": "auto"}) + assert model.invoke([HumanMessage("hi")]).content == "Hi!" + body = last_body(route) + assert "reasoning" not in body + assert body["reasoning_effort"] == "high" + + +@respx.mock +def test_reasoning_kwarg_without_effort_is_dropped() -> None: + route = mock_json(BASIC) + ChatInterfaze(api_key="t", reasoning={"summary": "auto"}).invoke([HumanMessage("hi")]) + body = last_body(route) + assert "reasoning" not in body + assert "reasoning_effort" not in body + + +def test_control_headers() -> None: + model = ChatInterfaze( + api_key="t", + show_additional_info=True, + bypass_moa=True, + bypass_cache=True, + default_headers={"x-custom": "1"}, + ) + assert model.default_headers == { + "x-custom": "1", + "x-show-additional-info": "true", + "x-interfaze-bypass-moa": "true", + "x-interfaze-bypass-cache": "true", + } + + +def test_no_control_headers_by_default() -> None: + assert ChatInterfaze(api_key="t").default_headers is None + + +@respx.mock +def test_streaming_asks_for_usage() -> None: + # langchain-openai only auto-enables this for OpenAI's own base URL. + route = mock_sse([chunk({"content": "hi"}), chunk({}, finish_reason="stop")]) + list(ChatInterfaze(api_key="t").stream([HumanMessage("hi")])) + assert last_body(route)["stream_options"] == {"include_usage": True} + + +def test_cache_key_distinguishes_header_values() -> None: + a = ChatInterfaze(api_key="k", default_headers={"x-tenant": "a"}) + b = ChatInterfaze(api_key="k", default_headers={"x-tenant": "b"}) + assert a._get_llm_string() != b._get_llm_string() + + +def test_control_header_replaces_a_differently_cased_one() -> None: + model = ChatInterfaze( + api_key="k", bypass_cache=True, default_headers={"X-Interfaze-Bypass-Cache": "false"} + ) + assert model.default_headers == {"x-interfaze-bypass-cache": "true"} + + +@pytest.mark.parametrize( + ("model_kwargs", "call_kwargs", "expected"), + [ + ({}, {"reasoning": {"effort": "high"}, "reasoning_effort": "low"}, "high"), + ({"reasoning": {"effort": "low"}}, {"reasoning_effort": "high"}, "high"), + ({"reasoning_effort": "low"}, {"reasoning": {"effort": "high"}}, "high"), + ({"reasoning_effort": "on"}, {}, "on"), + ], +) +def test_reasoning_effort_precedence( + model_kwargs: dict[str, Any], call_kwargs: dict[str, Any], expected: str +) -> None: + """Same ladder as the JS package: a per-call value always beats a model-level one.""" + model = ChatInterfaze(api_key="k", **model_kwargs) + assert model._get_request_payload([HumanMessage("x")], **call_kwargs)["reasoning_effort"] == expected + + +def test_cache_key_does_not_publish_header_values() -> None: + model = ChatInterfaze(api_key="k", default_headers={"x-tenant": "secret-tenant"}) + assert "secret-tenant" not in str(model._identifying_params) + assert "secret-tenant" not in model._get_llm_string() + + +def test_cache_key_shows_the_flags_we_own() -> None: + model = ChatInterfaze(api_key="k", bypass_cache=True) + assert model._identifying_params["interfaze_headers"] == ["x-interfaze-bypass-cache=true"] + + +def test_cache_key_separates_two_api_keys() -> None: + a = ChatInterfaze(api_key="sk_tenant_a") + b = ChatInterfaze(api_key="sk_tenant_b") + assert a._get_llm_string() != b._get_llm_string() + assert "sk_tenant_a" not in a._get_llm_string() diff --git a/python/tests/unit_tests/test_identity.py b/python/tests/unit_tests/test_identity.py new file mode 100644 index 0000000..8bbbbc5 --- /dev/null +++ b/python/tests/unit_tests/test_identity.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import respx +from langchain_core.messages import HumanMessage + +from interfaze_langchain import ChatInterfaze, __version__ +from tests.unit_tests.conftest import BASIC, mock_json + + +def test_provider_identity() -> None: + model = ChatInterfaze(api_key="t") + assert model._llm_type == "interfaze" + assert model._get_ls_params()["ls_provider"] == "interfaze" + assert model.lc_secrets == {"openai_api_key": "INTERFAZE_API_KEY"} + assert model.get_lc_namespace() == ["interfaze_langchain", "chat_models"] + assert model.metadata is not None + assert "interfaze-langchain" in model.metadata["lc_versions"] + + +def test_version_matches_pyproject() -> None: + # Read the raw line rather than tomllib, which is 3.11+ and this package is 3.10+. + pyproject = (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text() + declared = re.search(r'^version = "([^"]+)"', pyproject, re.MULTILINE) + assert declared is not None, "no version in pyproject.toml" + assert declared.group(1) == __version__ + + +@respx.mock +def test_model_provider_stamped_on_response() -> None: + mock_json(BASIC) + model = ChatInterfaze(api_key="t") + assert model.invoke([HumanMessage("hi")]).response_metadata["model_provider"] == "interfaze" diff --git a/python/tests/unit_tests/test_imports.py b/python/tests/unit_tests/test_imports.py index 2e48866..3aec8f1 100644 --- a/python/tests/unit_tests/test_imports.py +++ b/python/tests/unit_tests/test_imports.py @@ -1,7 +1,7 @@ -from langchain_interfaze import __all__ +from interfaze_langchain import __all__ -EXPECTED = ["ChatInterfaze"] +PUBLIC_EXPORTS = ["ChatInterfaze", "__version__"] -def test_all_imports() -> None: - assert sorted(__all__) == sorted(EXPECTED) +def test_public_exports_are_pinned() -> None: + assert sorted(__all__) == sorted(PUBLIC_EXPORTS) diff --git a/python/tests/unit_tests/test_inputs.py b/python/tests/unit_tests/test_inputs.py new file mode 100644 index 0000000..353e5b0 --- /dev/null +++ b/python/tests/unit_tests/test_inputs.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest +import respx +from interfaze import InterfazeError +from langchain_core.messages import HumanMessage + +from interfaze_langchain import ChatInterfaze +from tests.unit_tests.conftest import BASIC, VIDEO_URL, last_body, mock_json + + +@respx.mock +def test_video_block_converted_to_file_part() -> None: + route = mock_json(BASIC) + model = ChatInterfaze(api_key="t") + message = HumanMessage( + content=[ + {"type": "text", "text": "what happens in this clip?"}, + {"type": "video", "url": VIDEO_URL}, + ] + ) + model.invoke([message]) + content = last_body(route)["messages"][-1]["content"] + assert {"type": "file", "file": {"file_data": VIDEO_URL, "format": "video/mp4"}} in content + + +@respx.mock +def test_video_block_url_without_known_extension_omits_format() -> None: + route = mock_json(BASIC) + model = ChatInterfaze(api_key="t") + model.invoke([HumanMessage(content=[{"type": "video", "url": "https://example.com/clip"}])]) + assert last_body(route)["messages"][-1]["content"][0]["file"] == {"file_data": "https://example.com/clip"} + + +@respx.mock +def test_video_block_base64_converted_to_file_part() -> None: + route = mock_json(BASIC) + model = ChatInterfaze(api_key="t") + model.invoke([HumanMessage(content=[{"type": "video", "base64": "AAAA", "mime_type": "video/mp4"}])]) + content = last_body(route)["messages"][-1]["content"] + assert content[0]["type"] == "file" + assert content[0]["file"]["file_data"] == "data:video/mp4;base64,AAAA" + + +@respx.mock +def test_video_block_forwards_filename() -> None: + route = mock_json(BASIC) + model = ChatInterfaze(api_key="t") + model.invoke( + [HumanMessage(content=[{"type": "video", "url": VIDEO_URL, "extras": {"filename": "clip.mp4"}}])] + ) + file = last_body(route)["messages"][-1]["content"][0]["file"] + assert file["file_data"] == VIDEO_URL + assert file["filename"] == "clip.mp4" + + +@respx.mock +def test_video_block_file_id_raises() -> None: + model = ChatInterfaze(api_key="t") + with pytest.raises(InterfazeError, match="file_id"): + model.invoke([HumanMessage(content=[{"type": "video", "file_id": "file-123"}])]) + + +@respx.mock +def test_video_block_missing_source_raises() -> None: + model = ChatInterfaze(api_key="t") + with pytest.raises(InterfazeError, match="requires one of"): + model.invoke([HumanMessage(content=[{"type": "video"}])]) + + +@respx.mock +def test_file_id_rejected_on_any_block() -> None: + model = ChatInterfaze(api_key="k") + with pytest.raises(InterfazeError, match="file_id"): + model.invoke([HumanMessage(content=[{"type": "file", "file_id": "file-123"}])]) + + +def test_scalar_header_values_are_stringified() -> None: + model = ChatInterfaze(api_key="k", default_headers={"X-Retries": 3}) # ty:ignore[invalid-argument-type] + assert model.default_headers == {"x-retries": "3"} + + +@respx.mock +def test_file_id_rejected_in_the_openai_native_nesting() -> None: + model = ChatInterfaze(api_key="k") + with pytest.raises(InterfazeError, match="file_id"): + model.invoke([HumanMessage(content=[{"type": "file", "file": {"file_id": "file-abc"}}])]) + + +@respx.mock +def test_empty_mime_type_falls_through_to_the_default() -> None: + route = mock_json(BASIC) + ChatInterfaze(api_key="t").invoke( + [HumanMessage(content=[{"type": "video", "base64": "AAAA", "mime_type": ""}])] + ) + part = last_body(route)["messages"][-1]["content"][0] + assert part["file"] == {"file_data": "data:video/mp4;base64,AAAA", "format": "video/mp4"} diff --git a/python/tests/unit_tests/test_standard.py b/python/tests/unit_tests/test_standard.py index 3871f7d..8b704f3 100644 --- a/python/tests/unit_tests/test_standard.py +++ b/python/tests/unit_tests/test_standard.py @@ -4,7 +4,7 @@ from langchain_tests.unit_tests import ChatModelUnitTests -from langchain_interfaze import ChatInterfaze +from interfaze_langchain import ChatInterfaze class TestChatInterfazeUnit(ChatModelUnitTests): diff --git a/python/tests/unit_tests/test_stream.py b/python/tests/unit_tests/test_stream.py new file mode 100644 index 0000000..f6781bd --- /dev/null +++ b/python/tests/unit_tests/test_stream.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import respx +from langchain_core.callbacks import ( + AsyncCallbackHandler, + AsyncCallbackManager, + BaseCallbackHandler, + CallbackManager, +) +from langchain_core.messages import AIMessageChunk, HumanMessage +from pydantic import BaseModel + +from interfaze_langchain import ChatInterfaze +from tests.unit_tests.conftest import ( + PLAIN_STREAM, + REPEATED_SIDE, + STREAM_CHUNKS, + THINK_SPLIT, + chunk, + completion, + merge, + mock_json, + mock_sse, +) + + +@respx.mock +def test_streaming_strips_inline_tags_and_carries_precontext() -> None: + mock_sse(STREAM_CHUNKS) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("x")])) + text = "".join(c.content for c in chunks) # ty:ignore[no-matching-overload] + assert "" not in text + assert text == "Total is $12.34" + carriers = [c for c in chunks if c.additional_kwargs.get("precontext")] + assert carriers + assert carriers[0].additional_kwargs["precontext"][0]["name"] == "ocr" + + +@respx.mock +def test_streaming_recovers_reasoning_split_across_chunks() -> None: + mock_sse(THINK_SPLIT) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("x")])) + text = "".join(c.content for c in chunks) # ty:ignore[no-matching-overload] + assert "" not in text and text == "The sky is blue." + reasoning = [c for c in chunks if c.additional_kwargs.get("reasoning")] + assert reasoning and reasoning[0].additional_kwargs["reasoning"] == "Rayleigh scattering." + + +@respx.mock +def test_async_streaming_recovers_reasoning_split_across_chunks() -> None: + mock_sse(THINK_SPLIT) + model = ChatInterfaze(api_key="t") + + async def go() -> list[Any]: + return [c async for c in model.astream([HumanMessage("x")])] + + chunks = asyncio.run(go()) + text = "".join(c.content for c in chunks) + assert "" not in text and text == "The sky is blue." + reasoning = [c for c in chunks if c.additional_kwargs.get("reasoning")] + assert reasoning and reasoning[0].additional_kwargs["reasoning"] == "Rayleigh scattering." + + +@respx.mock +def test_streaming_plain_content_emits_no_side_channel_chunk() -> None: + mock_sse(PLAIN_STREAM) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("hi")])) + assert "".join(c.content for c in chunks) == "Hello world" # ty:ignore[no-matching-overload] + assert not any( + c.additional_kwargs.get("precontext") or c.additional_kwargs.get("reasoning") for c in chunks + ) + + +# Token callbacks must never see the raw side-channel tags. Core's stream() calls `_stream` +# without a run_manager, but the v2 protocol path passes one straight through, and +# ChatOpenAI fires on_llm_new_token before yielding — hence the explicit check here. +@respx.mock +def test_run_manager_tokens_are_filtered() -> None: + mock_sse(THINK_SPLIT) + model = ChatInterfaze(api_key="t") + seen: list[str] = [] + + class Tap(BaseCallbackHandler): + def on_llm_new_token(self, token: str | list[str | dict[str, Any]], **kwargs: Any) -> None: + seen.append(str(token)) + + manager = CallbackManager.configure(inheritable_callbacks=[Tap()]) + run_manager = manager.on_chat_model_start({}, [[HumanMessage("x")]])[0] + list(model._stream([HumanMessage("x")], run_manager=run_manager)) + assert "" not in "".join(seen) + assert "".join(seen) == "The sky is blue." + + +@respx.mock +async def test_async_run_manager_tokens_are_filtered() -> None: + mock_sse(THINK_SPLIT) + model = ChatInterfaze(api_key="t") + seen: list[str] = [] + + class Tap(AsyncCallbackHandler): + async def on_llm_new_token(self, token: str | list[str | dict[str, Any]], **kwargs: Any) -> None: + seen.append(str(token)) + + manager = AsyncCallbackManager.configure(inheritable_callbacks=[Tap()]) + run_manager = (await manager.on_chat_model_start({}, [[HumanMessage("x")]]))[0] + async for _ in model._astream([HumanMessage("x")], run_manager=run_manager): + pass + assert "".join(seen) == "The sky is blue." + + +@respx.mock +def test_stream_text_matches_filtered_content() -> None: + mock_sse(THINK_SPLIT) + gens = list(ChatInterfaze(api_key="t")._stream([HumanMessage("x")])) + assert all(g.text == g.message.content for g in gens) + + +@respx.mock +def test_streamed_side_fields_are_applied_once() -> None: + mock_sse(REPEATED_SIDE) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("hi")])) + for key in ("reasoning", "precontext", "vcache"): + assert sum(key in c.additional_kwargs for c in chunks) == 1, key + + merged = merge(chunks) + assert merged.additional_kwargs["reasoning"] == "why" + assert merged.response_metadata["reasoning"] == "why" + assert merged.additional_kwargs["precontext"] == [{"name": "ocr"}] + assert merged.additional_kwargs["vcache"] is True + assert merged.response_metadata["model_provider"] == "interfaze" + + +@respx.mock +def test_empty_wire_reasoning_does_not_suppress_inline_think() -> None: + # An empty `reasoning` on the envelope must not mark the field seen, or the genuine + # inline text recovered from the tail is dropped in its favour. + mock_sse([chunk({"content": "realok"}) | {"reasoning": ""}, chunk({}, "stop")]) + out = list(ChatInterfaze(api_key="t").stream([HumanMessage("hi")])) + got = [c.additional_kwargs["reasoning"] for c in out if "reasoning" in c.additional_kwargs] + assert got == ["real"] + + +@respx.mock +def test_vcache_is_deduped_so_it_stays_a_bool() -> None: + mock_sse( + [ + chunk({"content": "a"}) | {"vcache": True}, + chunk({"content": "b"}) | {"vcache": False}, + chunk({}, finish_reason="stop"), + ] + ) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("hi")])) + merged = merge(chunks) + assert merged.additional_kwargs["vcache"] is True + + +@respx.mock +def test_streamed_reasoning_not_repeated_by_final_chunk() -> None: + # The tag-derived tail must not re-emit a field the wire already delivered. + mock_sse([chunk({"content": "whyok"}) | {"reasoning": "why"}, chunk({}, "stop")]) + out = list(ChatInterfaze(api_key="t").stream([HumanMessage("hi")])) + assert sum("reasoning" in c.additional_kwargs for c in out) == 1 + + +@respx.mock +def test_unterminated_tag_recovers_text() -> None: + """A truncated response must not come back silently empty.""" + mock_sse( + [ + chunk({"content": "never closed and the real answer 42"}), + chunk({}, "length"), + ] + ) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("x")])) + body = "".join(c.content for c in chunks if isinstance(c.content, str)) + # Truncated mid-: the partial reasoning is metadata, not the answer. + assert body == "" + reasoning = [c.additional_kwargs["reasoning"] for c in chunks if c.additional_kwargs.get("reasoning")] + assert reasoning == ["never closed and the real answer 42"] + + +@respx.mock +def test_unmatched_tag_survives_non_streaming() -> None: + """Non-streaming has the whole body: an unmatched tag is prose, not a side channel.""" + mock_json(completion("Wrap your reasoning in tags.")) + res = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]) + assert res.content == "Wrap your reasoning in tags." + + +@respx.mock +def test_unterminated_tag_mid_text_does_not_duplicate_prefix() -> None: + mock_sse([chunk({"content": "The answer is 42. because reasons"}), chunk({}, "length")]) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("x")])) + body = "".join(c.content for c in chunks if isinstance(c.content, str)) + assert body == "The answer is 42. " + reasoning = [c.additional_kwargs["reasoning"] for c in chunks if c.additional_kwargs.get("reasoning")] + assert reasoning == ["because reasons"] + + +@respx.mock +def test_envelope_side_field_kept_alongside_inline() -> None: + mock_sse( + [ + chunk({"content": "INLINEHi"}), + chunk({}, "stop"), + { + "id": "req-test", + "object": "chat.completion.chunk", + "created": 1_700_000_000, + "model": "interfaze-beta", + "choices": [], + "reasoning": "ENVELOPE", + }, + ] + ) + merged = None + for c in ChatInterfaze(api_key="t").stream([HumanMessage("x")]): + merged = c if merged is None else merged + c + assert merged is not None + assert "ENVELOPE" in str(merged.additional_kwargs["reasoning"]) + assert "INLINE" in str(merged.additional_kwargs["reasoning"]) + + +@respx.mock +def test_empty_precontext_still_surfaces() -> None: + mock_json(completion("hi", precontext=[])) + md = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]).response_metadata + assert md["precontext"] == [] + + +@respx.mock +def test_generation_text_matches_stripped_content() -> None: + mock_json(completion("SECRETThe answer is 42")) + res = ChatInterfaze(api_key="t").generate([[HumanMessage("x")]]) + assert res.generations[0][0].text == "The answer is 42" + + +@respx.mock +def test_tail_recovered_when_visible_text_starts_with_whitespace() -> None: + mock_sse( + [ + chunk({"content": "why\nThe sky is"}), + chunk({"content": " blue because "}), + ] + ) + body = "".join( + c.content + for c in ChatInterfaze(api_key="t").stream([HumanMessage("x")]) + if isinstance(c.content, str) + ) + # the response completed, so an unmatched tag is prose and survives + assert body == "\nThe sky is blue because " + + +@respx.mock +def test_empty_envelope_value_does_not_block_inline_payload() -> None: + mock_json(completion('[{"name":"ocr"}]The sky is blue.', precontext=[])) + md = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]).response_metadata + assert md["precontext"] == [{"name": "ocr"}] + + +@respx.mock +def test_truncated_precontext_is_not_shown_as_content() -> None: + """A half-written is internal tool JSON, never the answer.""" + mock_sse( + [ + chunk({"content": "Total is "}), + chunk({"content": '[{"name":"ocr","result":{"ssn":"123-45-6789"'}, "length"), + ] + ) + body = "".join( + c.content + for c in ChatInterfaze(api_key="t").stream([HumanMessage("x")]) + if isinstance(c.content, str) + ) + assert "precontext" not in body + assert "123-45-6789" not in body + assert body == "Total is " + + +@respx.mock +def test_truncated_precontext_wins_over_a_later_think_mention() -> None: + """Split at the earliest unmatched tag, not the first one we happen to look for. + + Tool JSON can quote the string ``; scanning for think first would split there + and leak the raw `` — and the tool payload — into the answer. + """ + mock_json( + completion( + 'Total is [{"result":"page says here","ssn":"123-45-6789"', + finish_reason="length", + ) + ) + res = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]) + assert res.content == "Total is" + assert "precontext" not in str(res.content) + assert "123-45-6789" not in str(res.content) + assert "123-45-6789" not in str(res.response_metadata.get("reasoning") or "") + + +@respx.mock +def test_invoke_truncated_precontext_is_not_content() -> None: + """The truncation rule applies to invoke(), not just streaming.""" + mock_json(completion('Total is [{"ssn":"123-45-6789"', finish_reason="length")) + res = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]) + assert "123-45-6789" not in str(res.content) + + +@respx.mock +def test_invoke_truncated_think_becomes_reasoning() -> None: + mock_json(completion("SSN 123-45-6789 so", finish_reason="length")) + res = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]) + assert "123-45-6789" not in str(res.content) + assert "123-45-6789" in str(res.response_metadata["reasoning"]) + + +@respx.mock +def test_completed_response_keeps_prose_that_mentions_a_tag() -> None: + """An unmatched tag in a finished response is prose, not a side channel.""" + mock_json(completion("Wrap metadata in tags, then continue.")) + res = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]) + assert res.content == "Wrap metadata in tags, then continue." + + +@respx.mock +def test_final_side_chunk_stamps_model_provider() -> None: + mock_sse(THINK_SPLIT) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("x")])) + # core appends its own empty chunk_position="last" sentinel; every chunk we emit is stamped + ours = [c for c in chunks if c.chunk_position != "last"] + assert {c.response_metadata.get("model_provider") for c in ours} == {"interfaze"} + + +@respx.mock +def test_structured_output_streams_and_keeps_side_fields() -> None: + """with_structured_output streams through beta.chat.completions, which nests every + frame under "chunk" and omits `role` on the completion it assembles.""" + + class Ans(BaseModel): + answer: str + + # conftest's chunk() omits `role`, exactly as interfaze does after the first delta + frames = [chunk({"content": '{"answer":'}), chunk({"content": '"blue"}'}), chunk({}, "stop")] + frames[-1]["precontext"] = [{"name": "ocr", "output": "x"}] + mock_sse(frames) + model = ChatInterfaze(api_key="t").with_structured_output(Ans, include_raw=True) + out = [c for c in model.stream([HumanMessage("x")])] + assert any(c.get("parsed") == Ans(answer="blue") for c in out) + raw = next(c["raw"] for c in out if c.get("raw")) + assert raw.additional_kwargs["precontext"] == [{"name": "ocr", "output": "x"}] + + +@respx.mock +def test_roleless_deltas_still_produce_ai_message_chunks() -> None: + frames = [chunk({"content": "hi"}), chunk({}, "stop")] + mock_sse(frames) + chunks = list(ChatInterfaze(api_key="t").stream([HumanMessage("x")])) + assert all(isinstance(c, AIMessageChunk) for c in chunks) + assert "".join(c.content for c in chunks) == "hi" # ty:ignore[no-matching-overload] + + +_EVENT_FRAMES = [ + chunk({"content": "rHi"}), + { + "id": "req-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "interfaze-beta", + "choices": [], + "vcache": True, + "precontext": [{"name": "ocr"}], + }, + chunk({}, "length"), +] + + +@respx.mock +def test_astream_events_strips_tags_and_keeps_metadata() -> None: + """The js package needs a hand-written _streamChatModelEvents to reach this; python + gets it from the shared chunk path. Both must agree on what a v3 consumer sees.""" + + async def run() -> Any: + mock_sse(_EVENT_FRAMES) + async for ev in ChatInterfaze(api_key="t").astream_events([HumanMessage("x")], version="v2"): + if ev["event"] == "on_chat_model_end": + return ev["data"]["output"] + raise AssertionError("no on_chat_model_end event") + + out = asyncio.run(run()) + assert out.content == "Hi" + assert out.response_metadata["finish_reason"] == "length" + assert out.response_metadata["vcache"] is True + assert out.response_metadata["precontext"] == [{"name": "ocr"}] + assert out.response_metadata["reasoning"] == "r" + + +@respx.mock +def test_invoke_falls_through_an_empty_think_to_recovered_reasoning() -> None: + mock_json(completion("visiblepartial reasoning", finish_reason="length")) + res = ChatInterfaze(api_key="t").invoke([HumanMessage("x")]) + assert res.content == "visible" + assert res.response_metadata["reasoning"] == "partial reasoning" diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs new file mode 100644 index 0000000..911f2c0 --- /dev/null +++ b/scripts/check-versions.mjs @@ -0,0 +1,23 @@ +// Five files carry the version and two of them reach users as a User-Agent. A release +// cut from the wrong commit would otherwise publish the previous version, silently. +import { readFileSync } from "node:fs"; + +const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +const match = (path, re) => (read(path).match(re) ?? [])[1]; + +const versions = { + "python/pyproject.toml": match("python/pyproject.toml", /^version = "(.+)"$/m), + "python/interfaze_langchain/_version.py": match("python/interfaze_langchain/_version.py", /^__version__ = "(.+)"$/m), + "js/package.json": JSON.parse(read("js/package.json")).version, + "js/jsr.json": JSON.parse(read("js/jsr.json")).version, + "js/src/version.ts": match("js/src/version.ts", /VERSION = "(.+)"/), +}; + +// With no tag argument the five just have to agree with each other, which is what the +// PR check wants; on a release they also have to agree with the tag. +const tag = process.argv[2]?.replace(/^v/, "") || versions["js/package.json"]; +const wrong = Object.entries(versions).filter(([, version]) => version !== tag); + +for (const [file, version] of wrong) console.error(`::error file=${file}::${version} does not match ${tag}`); +if (wrong.length) process.exit(1); +console.log(`all five versions are ${tag}`);