From f83a1eb7688528a8009af35deae9b767bf9ed7ad Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 16 Sep 2026 18:19:37 -0400 Subject: [PATCH] Type the source that served a run The gateway's run envelope now carries an optional `source`: the customer-safe identity of the lane that actually served the call, in the same shape discovery already publishes under `lanes[].source`. Both SDKs dropped it on the floor, so a caller could not read which source served a run and feed that id back as `source` (or into `ignoreSources`) next time. Declare it on `RunResult` and `BareRunResult` in both languages, reusing the existing `DiscoverySource` type rather than declaring a second one, and record it in SPEC 2.3 beside the run-envelope field-presence erratum: the Go tag carries `omitempty`, so `source` is optional exactly like `resultId` and `jqError`. The synthetic fixtures are unchanged for the same reason they omit `resultId` - they model the success path. Co-Authored-By: Claude Fable 5.1 --- SPEC.md | 23 +++++++++-- packages/python/src/getanyapi/types.py | 16 +++++++- packages/python/tests/test_envelope.py | 49 +++++++++++++++++++++++- packages/typescript/src/core/types.ts | 14 +++++++ packages/typescript/tests/client.test.ts | 49 ++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 7 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5137ecc..210de2b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -415,6 +415,12 @@ export interface RunResult { jqError?: string; /** Optional server nudge when a large result was returned untrimmed. */ hint?: string; + /** + * The customer-safe identity of the lane that actually served this run - the same + * object discovery publishes under `lanes[].source`. Absent when the run names no + * resolvable lane. + */ + source?: DiscoverySource; } /** Discriminated union on `found`. When found is false, data is null. */ @@ -436,8 +442,8 @@ overload that returns `output` directly. **(v1 erratum) Run-envelope field presence.** The gateway's Go struct tags are the authoritative statement of what reaches the wire; a field without `omitempty` is ALWAYS sent. By that rule `output`, `provider`, `costUsd`, `items`, and `replayed` are REQUIRED, and -`hint`, `resultId`, and `jqError` are optional (omitted when empty). Both languages declare -exactly that set, on `RunResult` and `BareRunResult` alike. +`hint`, `resultId`, `jqError`, and `source` are optional (omitted when empty). Both languages +declare exactly that set, on `RunResult` and `BareRunResult` alike. **(v1 erratum) `items` is REQUIRED.** It was declared optional in both SDKs through v0.9.7 and is now required, for the same reason `replayed` became required: the gateway's field @@ -459,6 +465,14 @@ served from storage instead of running the SKU again; a replay is not billed twi caller can re-shape it for free via `GET /v1/results/{id}`. `jqError` reports why a requested jq reshape did not apply; the run was still billed and `output` carries the full result. +**(v1 erratum) Served source.** `source` is optional (its Go tag carries `omitempty`) and +names the lane that actually served the run, in the same customer-safe shape discovery +already publishes under `lanes[].source`: `{ id, name, kind, artworkKey }`. Both SDKs reuse +their existing `DiscoverySource` type rather than declaring a second one, so a caller can +feed `source.id` straight back as the `source` input (or into `ignoreSources`) on the next +call. It is omitted when the run names no resolvable lane; the internal routing provider +slug is never part of it, and `provider` stays the literal `"AnyAPI"`. + **(v1 erratum) Unretained replay output.** A replay can outlive the payload it replays: the gateway prunes stored payloads on a 24h TTL and never stores one over its size cap, and `output` carries no `omitempty`, so such a response is legally `{"output": null, ...}` with @@ -991,13 +1005,14 @@ class RunResult(BaseModel, Generic[T]): replayed: bool # required; the gateway always sends it result_id: str | None = None # alias "resultId" jq_error: str | None = None # alias "jqError" + source: DiscoverySource | None = None # the lane that served the run def unwrap(result: "RunResult[T]") -> T: """Return data when found, else raise NotFoundError.""" ``` -`items`, `replayed`, `result_id`, and `jq_error` mirror the TypeScript fields of 2.3 exactly, -including presence and optionality, on both `RunResult[T]` and `BareRunResult[T]`. `unwrap` +`items`, `replayed`, `result_id`, `jq_error`, and `source` mirror the TypeScript fields of +2.3 exactly, including presence and optionality, on both `RunResult[T]` and `BareRunResult[T]`. `unwrap` applies the same unretained-replay guard: a None `output` raises `AnyAPIError` (status 200) with the message described in 2.3, never a `ResultNotFoundError` and never a None typed as `T`. Both models additionally carry the `mode="before"` guard described in 2.3, so a null or diff --git a/packages/python/src/getanyapi/types.py b/packages/python/src/getanyapi/types.py index 8ddc0aa..1dd7625 100644 --- a/packages/python/src/getanyapi/types.py +++ b/packages/python/src/getanyapi/types.py @@ -132,6 +132,10 @@ class RunResult(BaseModel, Generic[T]): ``items`` is REQUIRED: the gateway sends it on every success envelope (its Go struct tag carries no ``omitempty``), including a metadata-only replay and the free re-read of a cached result. + + ``source`` is optional and names the lane that actually served the run, reusing + the discovery :class:`DiscoverySource` shape, so ``source.id`` can be fed back as + the ``source`` input (or into ``ignoreSources``) on the next call. """ model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -144,6 +148,10 @@ class RunResult(BaseModel, Generic[T]): result_id: str | None = Field(default=None, alias="resultId") jq_error: str | None = Field(default=None, alias="jqError") hint: str | None = None + #: The customer-safe identity of the lane that actually served this run, in the same + #: shape discovery publishes under ``lanes[].source``. None when the run names no + #: resolvable lane. + source: DiscoverySource | None = None @model_validator(mode="before") @classmethod @@ -172,8 +180,8 @@ class BareRunResult(BaseModel, Generic[T]): data payload directly. There is no not-found branch to discriminate, so ``unwrap`` returns ``output`` directly unless the payload was not retained. - ``items``, ``replayed``, ``result_id``, and ``jq_error`` carry the same meaning - and the same wire presence as on :class:`RunResult`. + ``items``, ``replayed``, ``result_id``, ``jq_error``, and ``source`` carry the same + meaning and the same wire presence as on :class:`RunResult`. """ model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -186,6 +194,10 @@ class BareRunResult(BaseModel, Generic[T]): result_id: str | None = Field(default=None, alias="resultId") jq_error: str | None = Field(default=None, alias="jqError") hint: str | None = None + #: The customer-safe identity of the lane that actually served this run, in the same + #: shape discovery publishes under ``lanes[].source``. None when the run names no + #: resolvable lane. + source: DiscoverySource | None = None @model_validator(mode="before") @classmethod diff --git a/packages/python/tests/test_envelope.py b/packages/python/tests/test_envelope.py index 0224115..79e5daa 100644 --- a/packages/python/tests/test_envelope.py +++ b/packages/python/tests/test_envelope.py @@ -7,7 +7,14 @@ import pytest from pydantic import ValidationError -from getanyapi import AnyAPIError, BareRunResult, NotFoundError, RunResult, unwrap +from getanyapi import ( + AnyAPIError, + BareRunResult, + DiscoverySource, + NotFoundError, + RunResult, + unwrap, +) from getanyapi.types import OutputFound, OutputNotFound @@ -200,3 +207,43 @@ def test_items_is_required_on_both_envelopes(model: Any) -> None: } ) assert "items" in str(exc.value) + + +def test_served_source_parses_as_a_discovery_source() -> None: + result = RunResult[dict[str, Any]].model_validate( + { + "output": {"found": True, "data": {"x": 1}}, + "provider": "AnyAPI", + "costUsd": 0.1, + "items": 1, + "replayed": False, + "source": { + "id": "otter", + "name": "Otter", + "kind": "anonymous", + "artworkKey": "otter", + }, + } + ) + source = result.source + assert isinstance(source, DiscoverySource) + assert source.id == "otter" + assert source.artwork_key == "otter" + # The routing provider is never named: the top-level provider stays AnyAPI. + assert result.provider == "AnyAPI" + # The wire shape round-trips unchanged. + dumped = result.model_dump(by_alias=True) + assert dumped["source"]["artworkKey"] == "otter" + + +def test_source_defaults_to_none_when_no_lane_is_named() -> None: + result = BareRunResult[dict[str, Any]].model_validate( + { + "output": {"x": 1}, + "provider": "AnyAPI", + "costUsd": 0.1, + "items": 1, + "replayed": False, + } + ) + assert result.source is None diff --git a/packages/typescript/src/core/types.ts b/packages/typescript/src/core/types.ts index 237c00a..ccfed98 100644 --- a/packages/typescript/src/core/types.ts +++ b/packages/typescript/src/core/types.ts @@ -38,6 +38,13 @@ export interface RunResult { jqError?: string; /** Optional server nudge when a large result was returned untrimmed. */ hint?: string; + /** + * The customer-safe identity of the lane that actually served this run - the same object + * discovery publishes under `lanes[].source`, so `source.id` can be fed straight back as + * the `source` input (or into `ignoreSources`) on the next call. Absent when the run + * names no resolvable lane. + */ + source?: DiscoverySource; } export type RequestStatus = @@ -99,6 +106,13 @@ export interface BareRunResult { jqError?: string; /** Optional server nudge when a large result was returned untrimmed. */ hint?: string; + /** + * The customer-safe identity of the lane that actually served this run - the same object + * discovery publishes under `lanes[].source`, so `source.id` can be fed straight back as + * the `source` input (or into `ignoreSources`) on the next call. Absent when the run + * names no resolvable lane. + */ + source?: DiscoverySource; } /** diff --git a/packages/typescript/tests/client.test.ts b/packages/typescript/tests/client.test.ts index 3b7debe..07ec0e6 100644 --- a/packages/typescript/tests/client.test.ts +++ b/packages/typescript/tests/client.test.ts @@ -3,6 +3,7 @@ import { AnyAPI, unwrap } from "../src/index.js"; import { AnyAPIError, NotFoundError } from "../src/index.js"; import type { AmazonReviewsData, + DiscoverySource, RunResult, } from "../src/index.js"; import { @@ -207,3 +208,51 @@ describe("replay metadata", () => { expect(res.jqError).toBeUndefined(); }); }); + +describe("served source", () => { + it("carries the lane that served the run, typed as a discovery source", async () => { + const { fetch } = mockFetch([ + { + body: foundEnvelope( + { items: [] }, + { + source: { + id: "otter", + name: "Otter", + kind: "anonymous", + artworkKey: "otter", + }, + }, + ), + }, + ]); + const client = new AnyAPI({ apiKey: "sk_test", fetch }); + + const res: RunResult = await client.run( + "amazon.reviews", + { product: "B07" }, + ); + + const source: DiscoverySource | undefined = res.source; + expect(source).toEqual({ + id: "otter", + name: "Otter", + kind: "anonymous", + artworkKey: "otter", + }); + // The routing provider is never named: the top-level provider stays AnyAPI. + expect(res.provider).toBe("AnyAPI"); + }); + + it("leaves source undefined when the run names no resolvable lane", async () => { + const { fetch } = mockFetch([{ body: foundEnvelope({ items: [] }) }]); + const client = new AnyAPI({ apiKey: "sk_test", fetch }); + + const res: RunResult = await client.run( + "amazon.reviews", + { product: "B07" }, + ); + + expect(res.source).toBeUndefined(); + }); +});