Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,12 @@ export interface RunResult<T> {
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. */
Expand All @@ -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<T>` and `BareRunResult<T>` alike.
`hint`, `resultId`, `jqError`, and `source` are optional (omitted when empty). Both languages
declare exactly that set, on `RunResult<T>` and `BareRunResult<T>` 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions packages/python/src/getanyapi/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
49 changes: 48 additions & 1 deletion packages/python/tests/test_envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions packages/typescript/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface RunResult<T> {
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 =
Expand Down Expand Up @@ -99,6 +106,13 @@ export interface BareRunResult<T> {
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;
}

/**
Expand Down
49 changes: 49 additions & 0 deletions packages/typescript/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<AmazonReviewsData> = 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<AmazonReviewsData> = await client.run(
"amazon.reviews",
{ product: "B07" },
);

expect(res.source).toBeUndefined();
});
});
Loading