Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3cf3956
feat: support OpenAI transcription replay
jpr5 Jul 30, 2026
cfbbe0a
test: cover transcription replay contracts
jpr5 Jul 30, 2026
89d4e5c
chore: release v1.38.0
jpr5 Aug 3, 2026
ead88d4
fix: preserve transcription terminal replay contracts
jpr5 Aug 3, 2026
c45bc66
fix: preserve realtime output audio configuration
jpr5 Aug 3, 2026
4b6cedb
test: make replay speed timing deterministic
jpr5 Aug 3, 2026
b5ce7df
test: strengthen replay timing contracts
jpr5 Aug 3, 2026
cf983c3
feat: validate inbound API keys
jpr5 Aug 3, 2026
52c9240
feat: support API keys in pytest helper
jpr5 Aug 3, 2026
7c6262e
docs: describe API key validation
jpr5 Aug 3, 2026
f3a21fb
test: stabilize CLI integration deadlines
jpr5 Aug 3, 2026
1451855
fix: scope API key egress policy to requests
jpr5 Aug 3, 2026
bb31f5c
test: cover API key client configuration
jpr5 Aug 3, 2026
d2e346c
ci: enforce npm release before pytest publish
jpr5 Aug 3, 2026
c26dcc3
test: isolate pytest subprocess plugin loading
jpr5 Aug 3, 2026
29645e1
ci: disable cache in pytest publish workflow
jpr5 Aug 3, 2026
0e74e57
fix: sequence pytest release and redact credentials
jpr5 Aug 4, 2026
bb356ac
docs: merge duplicate changelog heading
jpr5 Aug 4, 2026
19b5404
fix(realtime): input transcription config no longer hijacks a convers…
jpr5 Aug 4, 2026
3b84ac6
fix(transcription): terminate the streaming response with data: [DONE]
jpr5 Aug 4, 2026
ac7ffcc
fix(realtime): match live GA session, usage and match-count semantics
jpr5 Aug 4, 2026
09d370f
Merge origin/main into fix/live-transcribe-support-pr
jpr5 Aug 4, 2026
1c9008f
docs(changelog): record the journal credential-redaction fix in 1.38.0
jpr5 Aug 4, 2026
fcd8d56
fix(realtime): make synthesized transcription usage model-aware
jpr5 Aug 4, 2026
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"source": {
"source": "npm",
"package": "@copilotkit/aimock",
"version": "^1.35.1"
"version": "^1.38.0"
},
"description": "Fixture authoring skill for @copilotkit/aimock — LLM, multimedia (image/TTS/transcription/video), MCP, A2A, AG-UI, vector, embeddings, structured output, sequential responses, streaming physics, record/replay, agent loop patterns, and debugging"
}
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "aimock",
"version": "1.35.1",
"version": "1.38.0",
"description": "Fixture authoring guidance for @copilotkit/aimock — LLM, multimedia, MCP, A2A, AG-UI, vector, and service mocking",
"author": {
"name": "CopilotKit"
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/publish-pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0

Check failure

Code scanning / zizmor

runtime artifacts potentially vulnerable to a cache poisoning attack: enables caching by default Error

runtime artifacts potentially vulnerable to a cache poisoning attack: enables caching by default
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
with:
node-version: "24"

- name: Verify pinned npm release is published
run: |
VERSION=$(python -c "import sys; sys.path.insert(0, 'packages/aimock-pytest/src'); from aimock_pytest._version import AIMOCK_VERSION; print(AIMOCK_VERSION)")
npm view "@copilotkit/aimock@${VERSION}" version

- name: Install build tools
run: pip install hatch
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [Unreleased]

## [1.38.0] - 2026-08-03

### Added

- OpenAI transcription replay for `gpt-transcribe` HTTP streams and `gpt-live-transcribe` Realtime sessions. Recorded responses retain transcript languages and usage metadata; replay supports progressive transcript events, timing controls, strict fixture matching, and interruption behavior.
- Opt-in inbound API-key validation for HTTP, control, mount, and WebSocket boundaries. Validated test credentials are never forwarded by the generic record/proxy path; auth-enabled proxying requires a configured provider credential.
- API-key egress isolation is request-scoped across generic, AG-UI, fal, and video record paths. Authorization schemes are case-insensitive, and OpenRouter off-origin unsigned URLs never receive a configured provider credential.
- `aimock-pytest` `0.5.2` is released after npm `@copilotkit/aimock` `1.38.0`; its PyPI workflow verifies the pinned npm package is already published before building a wheel.

### Added

- **OpenRouter chat / LLM router simulation.** Requests whose original path starts with `/api/v1/` (point any OpenAI SDK at a `baseURL` ending `/api/v1`) are detected as OpenRouter and shaped to match real OpenRouter bytes: a `gen-` id prefix (a fixture `id` override still wins), a top-level `provider` (default = the winning model slug's author, fixture-overridable via `provider`), both `finish_reason` and `native_finish_reason` on every choice/delta, an always-present `system_fingerprint` and `service_tier` (null by default), `message.reasoning`, and a rich `usage` with a **fixture-scriptable** `cost` + `cost_details` (emitted only when a fixture supplies a cost — never fabricated), plus `is_byok` / `prompt_tokens_details` / `completion_tokens_details` when overridden. Callers on the plain OpenAI `/v1/...` base are byte-for-byte unchanged.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ Private and link-local addresses (loopback, RFC1918, CGNAT, cloud metadata, ULA,

On replay, `turnIndex` is a non-fatal disambiguator, not a hard reject gate: a content-matching fixture is served even when its scripted `turnIndex` differs from the request's assistant-message count. This kills false "no fixture matched" misses for multi-bubble agent runs (multi-step agents emit several assistant bubbles per logical turn). When a served fixture diverges from its scripted `turnIndex`, the match diagnostic carries `turnIndexRelaxed: true` and aimock logs a one-shot warning (at the `warn` log level — silent by default). To restore the legacy strict behavior where a defined `turnIndex` must equal the assistant count exactly, set `AIMOCK_STRICT_TURN_INDEX=1`. The record path is always strict regardless of this flag.

## API-key validation

By default aimock accepts all requests. Opt into inbound test-client validation with a programmatic option, top-level `aimock.json` field, or environment-only key list:

```ts
await createServer(fixtures, { auth: { apiKeys: ["test-key"] } });
```

```json
{ "auth": { "apiKeys": ["test-key"] } }
```

```bash
AIMOCK_API_KEYS=test-key,rotated-key npx @copilotkit/aimock --config aimock.json
```

Use `Authorization: Bearer <key>`, `Authorization: Key <key>`, `x-api-key`, `x-goog-api-key`, `api-key`, or `xi-api-key`. Every supplied credential must resolve to one configured key; mismatches return `401` with an OpenAI-compatible authentication error. HTTP routes, control APIs, mounts, and WebSocket upgrades are protected. Genuine CORS preflights plus `GET /health`, `GET /ready`, and `GET /metrics` remain public. This is inbound test access control, distinct from `record.providerKeys`; when enabled, proxying strips test credentials and requires a configured static provider credential before egress.

### aimock-owned upstream keys — `AIMOCK_PROVIDER_*_KEY`

In record or `--proxy-only` mode, aimock forwards the caller's auth header to the real provider unchanged. If your tests can only send a dummy placeholder key (e.g. an SDK that refuses to start without a non-empty API key), aimock can inject its own configured upstream key on a fixture-miss passthrough so the proxied call actually authenticates. Each provider has an independent env var, and the key is applied with the provider-correct wire scheme:
Expand Down
2 changes: 1 addition & 1 deletion charts/aimock/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ name: aimock
description: Mock infrastructure for AI application testing (OpenAI, Anthropic, Gemini, MCP, A2A, vector)
type: application
version: 0.1.0
appVersion: "1.35.1"
appVersion: "1.38.0"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/aimock",
"version": "1.37.4",
"version": "1.38.0",
"description": "Mock infrastructure for AI application testing — LLM APIs, image generation, image editing, text-to-speech, transcription, audio translation, audio generation, video generation, embeddings, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.",
"license": "MIT",
"keywords": [
Expand Down
11 changes: 9 additions & 2 deletions packages/aimock-pytest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,14 @@ aimock.reset() # alias for reset_fixtures()

```
--aimock-node PATH Path to node binary
--aimock-version VER aimock npm version (default: 1.35.1)
--aimock-version VER aimock npm version (default: 1.38.0)
--aimock-api-key KEY Inbound API key for the aimock child process
```

## API-key validation

Pass `pytest --aimock-api-key test-key` to protect the aimock child. The helper sends this key on all control API calls, and the child receives it through `AIMOCK_API_KEYS`, never through process arguments. Direct client calls must use `Authorization: Bearer test-key`. For direct construction, use `AIMockServer(node_manager, api_key="test-key")`.

## Environment Variables

| Variable | Description |
Expand Down Expand Up @@ -128,7 +133,9 @@ The `test-pytest.yml` workflow:
Tests run across a matrix of Python 3.10--3.13 and Node 20/22.

The `publish-pytest.yml` workflow publishes to PyPI on pushes to `main` when
the version in `pyproject.toml` has not already been published.
the version in `pyproject.toml` has not already been published. It first
verifies that the `AIMOCK_VERSION` pin exists on npm, so publish
`@copilotkit/aimock` before the corresponding `aimock-pytest` release.

## License

Expand Down
2 changes: 1 addition & 1 deletion packages/aimock-pytest/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "aimock-pytest"
version = "0.5.0"
version = "0.5.2"
description = "pytest fixtures for aimock — mock LLM APIs, multimedia, MCP, A2A, AG-UI, vector DBs, and more"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
44 changes: 25 additions & 19 deletions packages/aimock-pytest/src/aimock_pytest/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ def __init__(
node_manager: NodeManager,
port: int = 0,
fixtures_path: str | Path | None = None,
api_key: str | None = None,
) -> None:
self.node_manager = node_manager
self.port = port
self.fixtures_path = fixtures_path
self.api_key = api_key
self._proc: subprocess.Popen[str] | None = None
self._base_url: str | None = None
# Background stdout drainer state. The reader thread continuously
Expand Down Expand Up @@ -81,11 +83,15 @@ def start(self) -> str:
fixtures_arg,
]

child_env = os.environ.copy()
if self.api_key is not None:
child_env["AIMOCK_API_KEYS"] = self.api_key
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=child_env,
)
atexit.register(self.stop)

Expand Down Expand Up @@ -163,6 +169,16 @@ def url(self) -> str:

# ── control API methods ─────────────────────────────────────────────

def _control_headers(self) -> dict[str, str]:
api_key = getattr(self, "api_key", None)
return {"Authorization": f"Bearer {api_key}"} if api_key else {}

def _control_request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
headers = dict(kwargs.pop("headers", {}))
headers.update(self._control_headers())
request_fn = getattr(requests, method.lower())
return request_fn(f"{self.base_url}/__aimock{path}", headers=headers, **kwargs)

# Match-level option keys. These belong under the fixture's ``match``
# block: the server reads exactly these fields from ``entry.match`` in
# ``entryToFixture`` (src/fixture-loader.ts). This set MUST track that
Expand Down Expand Up @@ -218,8 +234,7 @@ def add_fixture(
fixture_match[key] = value
else:
fixture[key] = value
r = requests.post(
f"{self.base_url}/__aimock/fixtures",
r = self._control_request("POST", "/fixtures",
json={"fixtures": [fixture]},
timeout=5,
)
Expand Down Expand Up @@ -293,8 +308,7 @@ def load_fixtures(self, path: str | Path) -> AIMockServer:
f"got {type(data).__name__}"
)

r = requests.post(
f"{self.base_url}/__aimock/fixtures",
r = self._control_request("POST", "/fixtures",
json={"fixtures": fixtures},
timeout=5,
)
Expand All @@ -303,9 +317,7 @@ def load_fixtures(self, path: str | Path) -> AIMockServer:

def clear_fixtures(self) -> AIMockServer:
"""Delete all fixtures via ``DELETE /__aimock/fixtures``."""
requests.delete(
f"{self.base_url}/__aimock/fixtures", timeout=5
).raise_for_status()
self._control_request("DELETE", "/fixtures", timeout=5).raise_for_status()
return self

def reset(self) -> AIMockServer:
Expand All @@ -316,22 +328,18 @@ def reset(self) -> AIMockServer:
def reset_fixtures(self) -> AIMockServer:
"""Clear fixtures + generation state (and journal) via
``POST /__aimock/reset/fixtures``."""
requests.post(
f"{self.base_url}/__aimock/reset/fixtures", timeout=5
).raise_for_status()
self._control_request("POST", "/reset/fixtures", timeout=5).raise_for_status()
return self

def reset_journal(self) -> AIMockServer:
"""Clear ONLY the request journal, leaving fixtures intact, via
``POST /__aimock/reset/journal``."""
requests.post(
f"{self.base_url}/__aimock/reset/journal", timeout=5
).raise_for_status()
self._control_request("POST", "/reset/journal", timeout=5).raise_for_status()
return self

def get_journal(self) -> list[dict[str, Any]]:
"""Return all recorded journal entries."""
r = requests.get(f"{self.base_url}/__aimock/journal", timeout=5)
r = self._control_request("GET", "/journal", timeout=5)
r.raise_for_status()
return r.json() # type: ignore[no-any-return]

Expand All @@ -346,8 +354,7 @@ def next_error(
body: dict[str, Any] | None = None,
) -> AIMockServer:
"""Queue a one-shot error via ``POST /__aimock/error``."""
requests.post(
f"{self.base_url}/__aimock/error",
self._control_request("POST", "/error",
json={"status": status, "body": body or {}},
timeout=5,
).raise_for_status()
Expand Down Expand Up @@ -432,9 +439,8 @@ def _wait_for_ready_inner(self, timeout: int) -> str:
while time.monotonic() < health_deadline:
attempts += 1
try:
r = requests.get(
f"{url}/__aimock/health", timeout=0.5
)
headers = self._control_headers()
r = requests.get(f"{url}/__aimock/health", headers=headers, timeout=0.5)
if r.status_code == 200:
return url
except requests.RequestException:
Expand Down
6 changes: 4 additions & 2 deletions packages/aimock-pytest/src/aimock_pytest/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
downloads exactly this version when AIMOCK_CLI_PATH is not set, so it must point
at a published `@copilotkit/aimock` release and be bumped to the release that
contains any new server routes/features the client calls (e.g. the reset-split
control routes ship in the next release). Keep it tracking npm releases.
control routes ship in the next release). Release npm before publishing the
corresponding pytest package; the PyPI workflow verifies that this pin exists
on npm before it builds or uploads a wheel. Keep it tracking npm releases.
"""

AIMOCK_VERSION = "1.37.2"
AIMOCK_VERSION = "1.38.0"
13 changes: 9 additions & 4 deletions packages/aimock-pytest/src/aimock_pytest/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=AIMOCK_VERSION,
help=f"aimock npm package version to use (default: {AIMOCK_VERSION})",
)
group.addoption(
"--aimock-api-key",
default=None,
help="Inbound API key passed to the aimock child process",
)


@pytest.fixture(scope="session")
Expand All @@ -38,20 +43,20 @@ def _aimock_node_manager(request: pytest.FixtureRequest) -> NodeManager:


@pytest.fixture
def aimock(_aimock_node_manager: NodeManager) -> Generator[AIMockServer, None, None]:
def aimock(request: pytest.FixtureRequest, _aimock_node_manager: NodeManager) -> Generator[AIMockServer, None, None]:
"""Function-scoped aimock server. A fresh server is started for every
test that requests this fixture, and torn down afterwards."""
server = AIMockServer(_aimock_node_manager, port=0)
server = AIMockServer(_aimock_node_manager, port=0, api_key=request.config.getoption("--aimock-api-key"))
server.start()
yield server
server.stop()


@pytest.fixture(scope="session")
def aimock_session(_aimock_node_manager: NodeManager) -> Generator[AIMockServer, None, None]:
def aimock_session(request: pytest.FixtureRequest, _aimock_node_manager: NodeManager) -> Generator[AIMockServer, None, None]:
"""Session-scoped aimock server. One server is shared across all tests
that request this fixture."""
server = AIMockServer(_aimock_node_manager, port=0)
server = AIMockServer(_aimock_node_manager, port=0, api_key=request.config.getoption("--aimock-api-key"))
server.start()
yield server
server.stop()
69 changes: 69 additions & 0 deletions packages/aimock-pytest/tests/test_basic.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os
import subprocess
import sys
from unittest import mock

import requests
Expand Down Expand Up @@ -38,6 +41,72 @@ def test_server_starts(aimock):
assert r.json()["status"] == "ok"


def test_api_key_controls_and_client_requests(_aimock_node_manager):
"""The helper authenticates control traffic without bypassing client auth."""
from aimock_pytest import AIMockServer

server = AIMockServer(_aimock_node_manager, api_key="pytest-test-key")
try:
server.start()
server.on_message("hello", {"content": "keyed"})
body = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hello"}],
}
assert requests.post(f"{server.base_url}/v1/chat/completions", json=body).status_code == 401
response = requests.post(
f"{server.base_url}/v1/chat/completions",
json=body,
headers={"Authorization": "Bearer pytest-test-key"},
)
assert response.status_code == 200
assert requests.post(f"{server.base_url}/__aimock/fixtures", json={"fixtures": []}).status_code == 401
finally:
server.stop()


def test_aimock_api_key_option_authenticates_plugin_fixture_in_a_subprocess(tmp_path):
"""The pytest option reaches the child and the fixture's control client."""
test_file = tmp_path / "test_keyed_fixture.py"
test_file.write_text(
"""
import requests


def test_keyed_fixture(aimock):
aimock.on_message("hello", {"content": "keyed fixture"})
body = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]}
assert requests.post(f"{aimock.base_url}/v1/chat/completions", json=body).status_code == 401
response = requests.post(
f"{aimock.base_url}/v1/chat/completions",
json=body,
headers={"Authorization": "Bearer subprocess-key"},
)
assert response.status_code == 200
assert response.json()["choices"][0]["message"]["content"] == "keyed fixture"
""",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
"-m",
"pytest",
"-q",
"-p",
"aimock_pytest.plugin",
"--aimock-api-key",
"subprocess-key",
str(test_file),
],
env=os.environ.copy(),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr


def test_add_fixture_and_match(aimock):
"""Add a fixture via control API, then hit it."""
aimock.on_message("hello", {"content": "Hi there!"})
Expand Down
Loading
Loading