From 67dfbfe9ac2a55f146c07ffbed7b2e203b7fb657 Mon Sep 17 00:00:00 2001 From: jbeckwith-oai <269036200+jbeckwith-oai@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:04:55 +0000 Subject: [PATCH] chore(api): preserve upload helpers and Live connection defaults Castiron-Internal-PR: https://github.com/openai/openai-python-internal/pull/315 Castiron-Source-SHA: 03c343c63f0a648d2572841727e5801741117521 Castiron-Public-Base-SHA: 49e6757e464ce03b6f47c89740feaa75870ba168 --- .castiron.stats.yml | 10 +- .../castiron-custom-code-comment.yml | 127 ++- .github/workflows/castiron-custom-code.yml | 21 +- README.md | 41 +- docs/browser.md | 57 +- docs/castiron-generated-code-transition.md | 144 +++- docs/castiron-transition-inventory.json | 743 +++++++++++++++--- docs/realtime-translation.md | 14 +- helpers.md | 129 +-- scripts/castiron/CUSTOM_CODE.md | 73 +- scripts/castiron/custom_code_budget.py | 160 ++-- scripts/castiron/custom_code_policy.py | 481 ++++++++++++ scripts/castiron/custom_code_test_support.py | 1 + .../castiron/fixtures/github_publisher.cjs | 7 +- scripts/castiron/generated-files.json | 60 +- scripts/castiron/test_custom_code_github.py | 373 ++++++++- scripts/castiron/test_custom_code_policy.py | 587 ++++++++++++++ .../castiron/test_custom_code_publication.py | 336 +++++++- scripts/castiron/test_custom_code_report.py | 11 +- src/openai/_client.py | 41 +- src/openai/lib/responses_websocket/README.md | 13 +- .../beta/agents/environments/files.py | 100 +-- .../beta/agents/sessions/artifacts.py | 28 +- src/openai/resources/beta/beta.py | 7 +- .../resources/beta/responses/responses.py | 14 +- src/openai/resources/live/forks.py | 38 +- src/openai/resources/live/live.py | 22 +- src/openai/resources/live/sideband.py | 38 +- src/openai/resources/realtime/realtime.py | 14 +- src/openai/resources/responses/responses.py | 14 +- src/openai/resources/uploads/uploads.py | 157 +--- tests/lib/test_browser_sensitive.py | 4 +- tests/lib/test_browser_zoom.py | 4 +- 33 files changed, 3182 insertions(+), 687 deletions(-) create mode 100755 scripts/castiron/custom_code_policy.py create mode 100644 scripts/castiron/test_custom_code_policy.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index d1755b48aa..406734151d 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: dd7765a4-3f6a-4a04-aaca-19aaa7507cdd +generation_id: 9fa1f60f-3df4-4d35-8b22-19dbbb6c80fb openapi_spec_hash: 051ddca625ac3b40a3f9d7687ff56386 openapi_transformed_spec_hash: da601ee105aa5acf0deebbb9b3d87b8e -config_hash: a9d68fc59815e9884d653f40d0c9cc15 -codegen_sha: 0048f2a7447c3cfe8d0198e0d7549c378e4bf08f -codegen_hash: a7aa886594dd13f43e180a8516000bdf96481ed147ba52f0f2def6e243bb03b5 -public_codegen_sha: f99cb5bb5fc87965058d7055e382a25d1d064bcc +config_hash: 032fbaa89aa701b3e9a159015126f57a +codegen_sha: b345a3bfad1729f1640878441f304e9fc4787c36 +codegen_hash: f71f5997995e620542e459fbe3a23789b2fbacf139d5d0c805e79c456abd2f49 +public_codegen_sha: 75ba2ba983afb0a8823fae82670c5323c0ee9908 diff --git a/.github/workflows/castiron-custom-code-comment.yml b/.github/workflows/castiron-custom-code-comment.yml index 730d62e25a..85a6a460ce 100644 --- a/.github/workflows/castiron-custom-code-comment.yml +++ b/.github/workflows/castiron-custom-code-comment.yml @@ -16,7 +16,7 @@ concurrency: jobs: compute: name: Compute trusted custom-code report - if: contains(fromJSON('["pull_request", "merge_group"]'), github.event.workflow_run.event) && github.event.workflow_run.path == '.github/workflows/castiron-custom-code.yml' + if: contains(fromJSON('["pull_request", "merge_group"]'), github.event.workflow_run.event) && (github.event.workflow_run.path == '.github/workflows/castiron-custom-code.yml' || startsWith(github.event.workflow_run.path, '.github/workflows/castiron-custom-code.yml@')) runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -24,6 +24,12 @@ jobs: actions: read pull-requests: read outputs: + selected-policy-sha: ${{ steps.select-policy.outputs.policy_sha }} + selected-base-sha: ${{ steps.select-policy.outputs.base_sha }} + selected-head-sha: ${{ steps.select-policy.outputs.head_sha }} + selected-branch: ${{ steps.select-policy.outputs.target_branch }} + policy-reviews: ${{ steps.select-policy.outputs.policy_reviews }} + human-review-rules: ${{ steps.select-policy.outputs.human_review_rules }} number: ${{ steps.report.outputs.number }} artifact-id: ${{ steps.artifact.outputs.artifact-id }} artifact-run-attempt: ${{ github.run_attempt }} @@ -42,6 +48,32 @@ jobs: ref: main persist-credentials: false + # The selector and its module come from current main. The later evaluator + # loads its own policy helpers from the immutable pinned checkout. + - name: Select reviewed policy from the trusted target base + id: select-policy + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + GIT_CONFIG_COUNT: '2' + GIT_CONFIG_KEY_0: credential.helper + GIT_CONFIG_VALUE_0: '' + GIT_CONFIG_KEY_1: credential.https://github.com.helper + GIT_CONFIG_VALUE_1: '!gh auth git-credential' + run: | + python3 -I scripts/castiron/custom_code_budget.py select-policy \ + --repository "$REPOSITORY" --event-path "$GITHUB_EVENT_PATH" \ + --trusted-sha "$(git rev-parse HEAD)" \ + --repo "$RUNNER_TEMP/castiron-policy-objects.git" \ + --out "$RUNNER_TEMP/castiron-policy-selection.json" + + - name: Check out the authenticated main policy version + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.select-policy.outputs.policy_sha }} + path: .castiron-policy-checker + persist-credentials: false + - name: Compute from trusted main and target branch Git objects id: report if: github.event.workflow_run.event == 'pull_request' @@ -56,9 +88,9 @@ jobs: RUN_ID: ${{ github.event.workflow_run.id }} RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} run: | - python3 -I scripts/castiron/custom_code_report.py trusted-report \ + python3 -I .castiron-policy-checker/scripts/castiron/custom_code_report.py trusted-report \ --repo "$RUNNER_TEMP/castiron-objects.git" \ - --base "$(git rev-parse HEAD)" \ + --base "$(git -C .castiron-policy-checker rev-parse HEAD)" \ --repository "$REPOSITORY" --run-id "$RUN_ID" --run-attempt "$RUN_ATTEMPT" \ --out "$RUNNER_TEMP/castiron-custom-code" if test -f "$RUNNER_TEMP/castiron-custom-code/context.json"; then @@ -93,14 +125,15 @@ jobs: GIT_CONFIG_KEY_1: credential.https://github.com.helper GIT_CONFIG_VALUE_1: '!gh auth git-credential' run: | - trusted_sha=$(git rev-parse HEAD) + trusted_sha=$(git -C .castiron-policy-checker rev-parse HEAD) reuse=() if [[ "$SOURCE_EVENT" == pull_request ]]; then reuse=(--trusted-report-dir "$RUNNER_TEMP/castiron-custom-code") fi - python3 -I scripts/castiron/custom_code_budget.py github \ + python3 -I .castiron-policy-checker/scripts/castiron/custom_code_budget.py github \ --repository "$REPOSITORY" --event-path "$GITHUB_EVENT_PATH" \ --trusted-sha "$trusted_sha" --repo "$RUNNER_TEMP/castiron-objects.git" \ + --selected-policy-json "$RUNNER_TEMP/castiron-policy-selection.json" \ "${reuse[@]}" --out "$RUNNER_TEMP/custom-code-budget" - name: Add the budget to the run summary @@ -135,6 +168,12 @@ jobs: - name: Publish exact-head statuses after checking freshness uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + SELECTED_POLICY_SHA: ${{ needs.compute.outputs.selected-policy-sha }} + SELECTED_BASE_SHA: ${{ needs.compute.outputs.selected-base-sha }} + SELECTED_HEAD_SHA: ${{ needs.compute.outputs.selected-head-sha }} + SELECTED_BRANCH: ${{ needs.compute.outputs.selected-branch }} + POLICY_REVIEWS: ${{ needs.compute.outputs.policy-reviews }} + HUMAN_REVIEW_RULES: ${{ needs.compute.outputs.human-review-rules }} TARGET_BRANCH: ${{ needs.compute.outputs.target-branch }} POLICY_SHA: ${{ needs.compute.outputs.policy-sha }} BASE_SHA: ${{ needs.compute.outputs.base-sha }} @@ -146,6 +185,11 @@ jobs: script: | const event = context.payload.workflow_run; const {data: run} = await github.rest.actions.getWorkflowRun({...context.repo, run_id: event.id}); + const {data: workflow} = await github.rest.actions.getWorkflow({ + ...context.repo, workflow_id: 'castiron-custom-code.yml', + }); + if (!Number.isSafeInteger(run.workflow_id) || run.workflow_id !== workflow.id || + workflow.path !== '.github/workflows/castiron-custom-code.yml') return; if (run.event !== event.event || run.head_sha !== event.head_sha || run.run_attempt !== event.run_attempt || run.status !== 'completed' || run.path.split('@', 1)[0] !== '.github/workflows/castiron-custom-code.yml' || run.repository.full_name !== `${context.repo.owner}/${context.repo.repo}`) return; @@ -180,9 +224,10 @@ jobs: if (run.event !== 'merge_group' || !match) return; branch = match[1]; } - // These required contexts belong to main. Next reports are advisory: - // GitHub does not rebuild its queue when main's policy changes. - if (branch !== 'main') return; + // Bind every target to the trusted selector, including next-to-main retargeting. + fresh = fresh && process.env.SELECTED_BRANCH === branch && + process.env.SELECTED_BASE_SHA === base && process.env.SELECTED_HEAD_SHA === head && + process.env.SELECTED_POLICY_SHA === policy; fresh = fresh && evaluatedBranch === branch; const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; // Concurrency serializes writes, but does not order runs or retries. @@ -190,7 +235,8 @@ jobs: const order = [run.id, run.run_attempt, Number(context.runId), Number(process.env.PUBLISH_ATTEMPT)]; const marker = `[evaluation ${order.join(':')}]`; const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, {...context.repo, ref: head}); - const names = ['Castiron / budget-only change', 'Castiron / custom-code budget']; + const prefix = branch === 'next' ? 'Castiron next' : 'Castiron'; + const names = [`${prefix} / budget-only change`, `${prefix} / custom-code budget`]; for (const status of statuses) { if (!names.includes(status.context) || status.creator?.login !== 'github-actions[bot]') continue; const prior = status.description?.match(/\[evaluation (\d+):(\d+):(\d+):(\d+)\]$/); @@ -225,12 +271,50 @@ jobs: cursor = next; } while (true); const {data: target} = await github.rest.git.getRef({...context.repo, ref: `heads/${branch}`}); - fresh = fresh && base === target.object.sha && policy === target.object.sha; + fresh = fresh && base === target.object.sha && (branch === 'next' || policy === target.object.sha); fresh = fresh && member; } + if (branch === 'next' && run.event === 'pull_request') { + const {data: target} = await github.rest.git.getRef({...context.repo, ref: 'heads/next'}); + fresh = fresh && base === target.object.sha; + } + // Selection is not a lasting approval. Recheck live reviews after + // compute and immediately before attaching required statuses. + if (branch === 'next') { + try { + const proofs = JSON.parse(process.env.POLICY_REVIEWS); + const rules = JSON.parse(process.env.HUMAN_REVIEW_RULES); + if (!Array.isArray(proofs) || rules.version !== 1 || !Array.isArray(rules.associations) || + typeof rules.negative_pattern !== 'string') throw new Error('Missing trusted review contract'); + const negative = new RegExp(rules.negative_pattern, 'i'); + const seen = new Set(); + for (const proof of proofs) { + if (!Number.isSafeInteger(proof.number) || proof.number <= 0 || !/^[0-9a-f]{40}$/.test(proof.head_sha) || + seen.has(proof.number) || (run.event === 'pull_request' && (proof.number !== number || proof.head_sha !== head))) { + throw new Error('Invalid policy review receipt'); + } + seen.add(proof.number); + const {data: pr} = await github.rest.pulls.get({...context.repo, pull_number: proof.number}); + if (pr.state !== 'open' || pr.head.sha !== proof.head_sha || pr.base.ref !== 'next' || + pr.base.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) throw new Error('Policy PR changed'); + const reviews = await github.paginate(github.rest.pulls.listReviews, {...context.repo, pull_number: proof.number, per_page: 100}); + const latest = new Map(); + for (const review of reviews) { + if (review.state === 'PENDING') continue; + const login = review.user.login; + if (!latest.has(login) || review.id > latest.get(login).id) latest.set(login, review); + } + const approved = [...latest.values()].some(review => review.state === 'APPROVED' && + review.commit_id === proof.head_sha && review.user.type === 'User' && + rules.associations.includes(review.author_association) && !review.user.login.endsWith('[bot]') && + review.user.login !== pr.user.login && !negative.test(review.body || '')); + if (!approved) throw new Error('Human policy approval is no longer valid'); + } + } catch { fresh = false; } + } for (const [name, result] of [ - ['Castiron / budget-only change', process.env.ISOLATION_RESULT], - ['Castiron / custom-code budget', process.env.BUDGET_RESULT], + [names[0], process.env.ISOLATION_RESULT], + [names[1], process.env.BUDGET_RESULT], ]) { const state = fresh && result === 'success' ? 'success' : 'failure'; const description = !fresh ? 'Evaluation unavailable or queue base changed; inspect the trusted run and rerun.' @@ -289,14 +373,27 @@ jobs: script: | const marker = ''; const run = context.payload.workflow_run; - if (run.event !== 'pull_request' || run.path !== '.github/workflows/castiron-custom-code.yml') return; + const {data: workflow} = await github.rest.actions.getWorkflow({ + ...context.repo, workflow_id: 'castiron-custom-code.yml', + }); + if (!Number.isSafeInteger(run.workflow_id) || run.workflow_id !== workflow.id || + workflow.path !== '.github/workflows/castiron-custom-code.yml') return; + if (run.event !== 'pull_request' || run.path.split('@', 1)[0] !== '.github/workflows/castiron-custom-code.yml') return; let pulls = run.pull_requests?.length ? run.pull_requests : await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {...context.repo, commit_sha: run.head_sha}); if (!pulls.length) pulls = await github.paginate(github.rest.pulls.list, { ...context.repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`, }); + const current = []; for (const pull of pulls) { - const {data: current} = await github.rest.pulls.get({...context.repo, pull_number: pull.number}); - if (current.state !== 'open' || current.head.sha !== run.head_sha) continue; + const {data: pr} = await github.rest.pulls.get({...context.repo, pull_number: pull.number}); + if (pr.state === 'open' && pr.head.sha === run.head_sha && + ['main', 'next'].includes(pr.base.ref) && pr.base.repo.full_name === `${context.repo.owner}/${context.repo.repo}`) current.push(pr); + } + // Match trusted compute's supported-target and main-preference selection. + const main = current.filter(pr => pr.base.ref === 'main'); + const selected = main.length ? main : current; + if (selected.length !== 1) return; + for (const pull of selected) { const comments = await github.paginate(github.rest.issues.listComments, {...context.repo, issue_number: pull.number}); const previous = comments.find(c => c.user?.type === 'Bot' && c.user?.login === 'github-actions[bot]' && c.body?.startsWith(marker)); const prior = previous?.body?.match(//); diff --git a/.github/workflows/castiron-custom-code.yml b/.github/workflows/castiron-custom-code.yml index c1df96512f..bde6e47d73 100644 --- a/.github/workflows/castiron-custom-code.yml +++ b/.github/workflows/castiron-custom-code.yml @@ -27,6 +27,24 @@ jobs: steps: - run: echo 'The workflow_run handler on main evaluates this candidate independently.' + # Candidate tests run on a separate runner and never precede a token-bearing step. + candidate-tests: + if: github.event_name == 'pull_request' + name: Castiron / reporter tests + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out the pull request + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Test snapshot isolation and the custom-code budget + run: python3 -m unittest discover -s scripts/castiron -p 'test_custom_code*.py' + report: if: github.event_name == 'pull_request' name: Castiron / baseline consistency @@ -56,9 +74,6 @@ jobs: - name: Verify the reporter matches its generated workflow run: printf '%s %s\n' "$REPORTER_SHA256" scripts/castiron/custom_code_report.py | sha256sum --check --strict - - name: Test snapshot isolation and the custom-code budget - run: python3 -m unittest discover -s scripts/castiron -p 'test_custom_code*.py' - - name: Validate the codegen hash and report custom code env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 04f773f4f8..1fabef6a81 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,7 @@ client = OpenAI( workload_identity={ "identity_provider_id": "idp-123", "service_account_id": "sa-456", - "provider": k8s_service_account_token_provider( - "/var/run/secrets/kubernetes.io/serviceaccount/token" - ), + "provider": k8s_service_account_token_provider("/var/run/secrets/kubernetes.io/serviceaccount/token"), }, ) @@ -400,13 +398,12 @@ Basic text based example: import asyncio from openai import AsyncOpenAI + async def main(): client = AsyncOpenAI() async with client.realtime.connect(model="gpt-realtime-2") as connection: - await connection.session.update( - session={"type": "realtime", "output_modalities": ["text"]} - ) + await connection.session.update(session={"type": "realtime", "output_modalities": ["text"]}) await connection.conversation.item.create( item={ @@ -427,6 +424,7 @@ async def main(): elif event.type == "response.done": break + asyncio.run(main()) ``` @@ -442,7 +440,7 @@ client = AsyncOpenAI() async with client.realtime.connect(model="gpt-realtime-2") as connection: ... async for event in connection: - if event.type == 'error': + if event.type == "error": print(event.error.type) print(event.error.code) print(event.error.event_id) @@ -845,10 +843,10 @@ In an API response, a field may be explicitly `null`, or missing entirely; in ei ```py if response.my_field is None: - if 'my_field' not in response.model_fields_set: - print('Got json like {}, without a "my_field" key present at all.') - else: - print('Got json like {"my_field": null}.') + if "my_field" not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') ``` ### Accessing raw response data (e.g. headers) @@ -860,13 +858,15 @@ from openai import OpenAI client = OpenAI() response = client.chat.completions.with_raw_response.create( - messages=[{ - "role": "user", - "content": "Say this is a test", - }], + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], model="gpt-5.5", ) -print(response.headers.get('X-My-Header')) +print(response.headers.get("X-My-Header")) completion = response.parse() # get the object that `chat.completions.create()` would have returned print(completion) @@ -1077,8 +1077,8 @@ By default the library closes underlying HTTP connections whenever the client is from openai import OpenAI with OpenAI() as client: - # make requests here - ... + # make requests here + ... # HTTP client is now closed ``` @@ -1093,9 +1093,7 @@ from openai import AsyncOpenAI async def main() -> None: async with AsyncOpenAI() as client: - response = await client.responses.create( - model="gpt-5.5", input="Say this is a test" - ) + response = await client.responses.create(model="gpt-5.5", input="Say this is a test") print(response.output_text) @@ -1242,6 +1240,7 @@ You can determine the version that is being used at runtime with: ```py import openai + print(openai.__version__) ``` diff --git a/docs/browser.md b/docs/browser.md index d4ce04a33e..fd592ad399 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -41,7 +41,10 @@ import os from openai import AsyncOpenAI from openai.lib.browser import ( - AsyncBrowserToolset, BrowserError, BrowserState, TabInput, + AsyncBrowserToolset, + BrowserError, + BrowserState, + TabInput, ) from openai.lib.responses_tool_runner import AsyncResponsesToolRunner from playwright.async_api import Page, async_playwright @@ -52,8 +55,7 @@ class Reader: self.page = page async def state(self) -> BrowserState: - return {"tabs": [{"id": "one", "url": self.page.url, - "title": await self.page.title(), "active": True}]} + return {"tabs": [{"id": "one", "url": self.page.url, "title": await self.page.title(), "active": True}]} async def get_page_text(self, args: TabInput) -> str: if args["tab_id"] not in (None, "one"): @@ -75,8 +77,11 @@ async def main() -> None: browser = AsyncBrowserToolset(Reader(page), members=["get_page_text"]) response = await AsyncResponsesToolRunner(browser=browser).run( client, - {"model": os.environ["OPENAI_MODEL"], - "input": "Read the page and tell me when the cafe closes.", "store": False}, + { + "model": os.environ["OPENAI_MODEL"], + "input": "Read the page and tell me when the cafe closes.", + "store": False, + }, max_steps=3, ) print(response.output_text) @@ -387,17 +392,28 @@ Register a normal function with its Responses function schema and an executor: ```python from openai.lib.responses_tool_runner import ( - AsyncRegisteredFunction, AsyncResponsesToolRunner, + AsyncRegisteredFunction, + AsyncResponsesToolRunner, ) + async def lookup(args: dict[str, object]) -> str: return str(args["reference"]) + registered = AsyncRegisteredFunction( - tool={"type": "function", "name": "lookup", "description": "Look up a reference", - "parameters": {"type": "object", "properties": {"reference": {"type": "string"}}, - "required": ["reference"], "additionalProperties": False}, - "strict": True}, + tool={ + "type": "function", + "name": "lookup", + "description": "Look up a reference", + "parameters": { + "type": "object", + "properties": {"reference": {"type": "string"}}, + "required": ["reference"], + "additionalProperties": False, + }, + "strict": True, + }, execute=lookup, ) # Pass your existing AsyncComputerToolset and AsyncBrowserToolset: @@ -423,14 +439,18 @@ from pydantic import BaseModel from openai import pydantic_responses_function_tool from openai.lib.responses_tool_runner import AsyncRegisteredFunction + class Lookup(BaseModel): reference: str + async def lookup(args: Lookup) -> str: return args.reference + registered = AsyncRegisteredFunction.from_tool( - pydantic_responses_function_tool(Lookup), execute=lookup, + pydantic_responses_function_tool(Lookup), + execute=lookup, ) ``` Use the existing SDK Responses Pydantic wrapper, as returned by @@ -644,14 +664,17 @@ The same whole-pair and pending-effect rules apply to in-memory runs: ```python from openai.lib.responses_tool_runner import ContextBudgetDecision, ContextBudgetSnapshot + def select_completed_results(snapshot: ContextBudgetSnapshot) -> ContextBudgetDecision: return { "drop_call_ids": snapshot["eligible_call_ids"], "summary": "Older tool outputs remain in the application's full ledger.", } + response = await runner.run( - client, params, + client, + params, context_budget={ "max_input_bytes": 240_000, "max_input_images": 3, @@ -736,9 +759,12 @@ The Fieldwork demo uses a local rehearsal and independently checks the saved res ```python from openai import AsyncOpenAI from openai.lib.decisions_tool_runner import ( - AsyncDecisionCandidate, AsyncDecisionsToolRunner, DecisionObservation, + AsyncDecisionCandidate, + AsyncDecisionsToolRunner, + DecisionObservation, ) + async def complete(model: str, task: str, page) -> str: async def observe() -> DecisionObservation: return await page.locator("body").inner_text() @@ -747,6 +773,7 @@ async def complete(model: str, task: str, page) -> str: async def mark_reviewed() -> None: # Check the current page URL and request permission here when needed. await page.get_by_role("button", name="Mark reviewed").click() + return [ {"id": "review", "description": "Mark the document reviewed", "execute": mark_reviewed}, {"id": "done", "description": "The document is already reviewed", "done": True}, @@ -757,7 +784,9 @@ async def complete(model: str, task: str, page) -> str: async with AsyncOpenAI() as client: result = await AsyncDecisionsToolRunner( - observe=observe, candidates=candidates, verify=verify, + observe=observe, + candidates=candidates, + verify=verify, ).run(client, {"model": model, "input": task}, max_steps=8) return result["status"] ``` diff --git a/docs/castiron-generated-code-transition.md b/docs/castiron-generated-code-transition.md index d25c482290..b937140ba8 100644 --- a/docs/castiron-generated-code-transition.md +++ b/docs/castiron-generated-code-transition.md @@ -6,56 +6,152 @@ Eliminate handwritten patches in generated-owned files while preserving every ex No removals or renames of public symbols, new required arguments, narrower input/return types, dependency or Python-floor changes, altered defaults, new payload limits, changed warnings, or changed sync/async behavior are authorized by this transition. Deprecated methods and compatibility import paths remain supported. -## Current measurement — 2026-10-21 +## Verified integration candidate — 2026-10-22 + +Private candidate #315 measures **32 mixed files; 5,632 additions + 1,213 deletions = 6,845 custom patch lines**, with **37,881 integrated versus 33,462 generated physical lines**. Its released baseline contains 48 mixed files / 9,173 patch lines. This is a private candidate reduction, not a merged public result. + +Evaluated SDK code: `5fcd05c9064e5bcfa820aed283d9570e1f997255`; released baseline: `49e6757e464ce03b6f47c89740feaa75870ba168`. Normal generation used merged compiler `6959b4685e426934700ebb40c159e382714c8542`, private checkpoint `b345a3bfad1729f1640878441f304e9fc4787c36`, generation `9fa1f60f-3df4-4d35-8b22-19dbbb6c80fb`, and content hash `f71f5997995e620542e459fbe3a23789b2fbacf139d5d0c805e79c456abd2f49`. The unchanged reporter verified real checkpoint ancestry and recorded content hashes with `--fetch --require-head-hash`. + +The API specification and dependency manifests/locks remain unchanged. Configuration changes remain limited to the eight upload/agent helper, alias and Live queue paths. The seven polling paths remain a separate follow-up; released polling runtime additions are preserved. No generation ownership is removed, no new mixed files are introduced, and no budget or exclusion is changed. + +16 files now match generation byte for byte, eliminating 1,838 old patch lines. The net patch reduction is 2,328; changes to the generated baseline also affect the remaining patches. These counts include comments, blank lines and moves and do not represent removed executable statements. + +| File now matching generation | Integrated = generated lines | Previous patch | +| --- | ---: | ---: | +| `scripts/castiron/custom_code_budget.py` | 702 | 61 | +| `scripts/castiron/custom_code_report.py` | 1250 | 206 | +| `scripts/castiron/fixtures/github_publisher.cjs` | 69 | 7 | +| `scripts/castiron/test_custom_code_budget.py` | 773 | 70 | +| `scripts/castiron/test_custom_code_github.py` | 978 | 31 | +| `scripts/castiron/test_custom_code_publication.py` | 873 | 27 | +| `scripts/castiron/test_custom_code_report.py` | 1084 | 621 | +| `src/openai/resources/beta/agents/environments/files.py` | 604 | 127 | +| `src/openai/resources/beta/agents/sessions/artifacts.py` | 573 | 10 | +| `src/openai/resources/beta/beta.py` | 266 | 21 | +| `src/openai/resources/live/forks.py` | 1460 | 141 | +| `src/openai/resources/live/live.py` | 1649 | 125 | +| `src/openai/resources/live/sideband.py` | 1427 | 159 | +| `src/openai/resources/uploads/uploads.py` | 652 | 221 | +| `src/openai/types/__init__.py` | 150 | 1 | +| `tests/api_resources/admin/organization/projects/test_api_keys.py` | 442 | 10 | + +Remaining files whose patch size changed: + +| File | Previous patch | Candidate patch | +| --- | ---: | ---: | +| `src/openai/resources/beta/responses/responses.py` | 214 | 110 | +| `src/openai/resources/realtime/realtime.py` | 134 | 12 | +| `src/openai/resources/realtime/translations/translations.py` | 138 | 52 | +| `src/openai/resources/responses/responses.py` | 1096 | 994 | +| `tests/api_resources/test_videos.py` | 88 | 12 | + +### Remaining candidate inventory -Public main `b668ee88e7cb670704a0fd300152da29666b26cc` contains **42 mixed files; 6,106 additions + 1,292 deletions = 7,398 custom patch lines**, with **46,685 integrated physical lines versus 41,871 generated physical lines**. The fixed October 19 audit below remains historical; the JSON records this refresh separately under `current_audit`. +| File | Integrated lines | Generated lines | Added | Deleted | Patch | Track | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| `src/openai/resources/beta/threads/runs/runs.py` | 3161 | 2052 | 1290 | 181 | 1471 | T4 | +| `src/openai/resources/responses/responses.py` | 5181 | 4335 | 920 | 74 | 994 | T5 | +| `src/openai/resources/audio/transcriptions.py` | 1089 | 1403 | 287 | 601 | 888 | T3 | +| `src/openai/_client.py` | 2440 | 1741 | 759 | 60 | 819 | T2 | +| `src/openai/resources/chat/completions/completions.py` | 3456 | 2854 | 603 | 1 | 604 | T6 | +| `src/openai/resources/beta/threads/threads.py` | 2009 | 1626 | 390 | 7 | 397 | T4 | +| `src/openai/resources/vector_stores/file_batches.py` | 867 | 628 | 242 | 3 | 245 | T7 | +| `src/openai/resources/vector_stores/files.py` | 1032 | 813 | 220 | 1 | 221 | T7 | +| `src/openai/__init__.py` | 505 | 321 | 185 | 1 | 186 | T2 | +| `src/openai/resources/videos.py` | 1457 | 1306 | 153 | 2 | 155 | T10 | +| `api.md` | 2090 | 1979 | 119 | 8 | 127 | T16 | +| `src/openai/resources/beta/agents/sessions/sessions.py` | 1304 | 1202 | 113 | 11 | 124 | T9 | +| `src/openai/resources/audio/translations.py` | 374 | 409 | 40 | 75 | 115 | T3 | +| `src/openai/resources/beta/responses/responses.py` | 5575 | 5575 | 55 | 55 | 110 | T5 | +| `src/openai/resources/webhooks/webhooks.py` | 1146 | 1061 | 89 | 4 | 93 | T12 | +| `src/openai/resources/webhooks/__init__.py` | 42 | 73 | 24 | 55 | 79 | T12 | +| `src/openai/resources/realtime/translations/translations.py` | 1152 | 1136 | 34 | 18 | 52 | T11 | +| `src/openai/resources/embeddings.py` | 263 | 257 | 27 | 21 | 48 | T13 | +| `src/openai/resources/files.py` | 826 | 800 | 26 | 0 | 26 | T14 | +| `src/openai/resources/realtime/api.md` | 149 | 169 | 0 | 20 | 20 | T16 | +| `src/openai/resources/realtime/realtime.py` | 1660 | 1654 | 9 | 3 | 12 | T11 | +| `src/openai/types/decision_input_image_param.py` | 47 | 37 | 11 | 1 | 12 | T1 | +| `src/openai/types/decision_input_text_param.py` | 33 | 23 | 11 | 1 | 12 | T1 | +| `tests/api_resources/test_videos.py` | 939 | 927 | 12 | 0 | 12 | T10 | +| `src/openai/types/webhooks/__init__.py` | 73 | 71 | 3 | 1 | 4 | T1 | +| `src/openai/types/webhooks/unwrap_webhook_event.py` | 74 | 74 | 2 | 2 | 4 | T1 | +| `src/openai/types/responses/tool.py` | 398 | 399 | 1 | 2 | 3 | T1 | +| `src/openai/types/responses/tool_param.py` | 398 | 395 | 3 | 0 | 3 | T1 | +| `src/openai/types/webhooks/live_call_incoming_webhook_event.py` | 71 | 70 | 2 | 1 | 3 | T1 | +| `src/openai/types/fine_tuning/fine_tuning_job_integration.py` | 5 | 7 | 0 | 2 | 2 | T1 | +| `src/openai/types/realtime/conversation_item_input_audio_transcription_delta_event.py` | 40 | 40 | 1 | 1 | 2 | T1 | +| `src/openai/types/realtime/realtime_error_event.py` | 25 | 25 | 1 | 1 | 2 | T1 | -Verified public snapshot: `efa5c8db4dbf18180716aff72b9a58d1a9d6d2d6`; generation `d25554dd-0197-4996-acdc-7aade76e2045`; generated-content SHA-256 `7c1bfd93466328a0bae3a67e40c27251bc2152767cd7cf0c12835d7fac647efd`. The unchanged reporter extracted from this main revision (SHA-256 `6b7cfda1f85426daaa2e4f6186e2d1b20ac638dadaa54be8142ecec89fefd880`) verified metadata, ancestry and content hash directly, without the earlier I/O workaround. Reproduce the report below with both `--base` and `--head` set to this main SHA. +Machine-readable evidence is under `pending_candidate_audit` in [castiron-transition-inventory.json](castiron-transition-inventory.json). -All 39 remaining files after #4113 retain their previous patch sizes. Three reporting files now add **47 formatting-only patch lines**: `custom_code_budget.py` (25), `test_custom_code_budget.py` (21), and `test_custom_code_github.py` (1). Generated and integrated ASTs match for all three. This is a regression from the measured 39 files / 7,351 lines, not progress toward zero. The original reporting-test customization remains removed. Current policy remains 10,000 lines, with 2,602 lines of headroom. +Validation on this exact code head: the normal SDK lint script passed Ruff, Pyright (zero errors), Mypy (2,047 files) and import verification; 1,606 runtime/client/security compatibility tests passed with 16 skips; 159 reporting tests ran successfully with one skip (158 passed). Existing public declarations and decorators are unchanged; the only added declarations are two private fallback methods. Dependency manifests, lockfile, release/version metadata, API reference, service-account documentation and the prepared translations module are byte-identical to the released baseline. Independent review verified all 1,971 manifest hashes/modes against the pure checkpoint, all canonical reporting/workflow bytes, and the unchanged eight-path configuration scope. -The compiler re-emits shared repository tooling after SDK formatting to preserve cross-language byte identity. The SDK formatter subsequently changes the noncanonical template formatting. Renew T0 by canonicalizing the shared templates and verifying formatter convergence; preserve the shared-tooling invariant, reporter semantics and all policy gates. A source-only restore would not establish durability. No source-family reduction is claimed until verified generated output lands. +The generated WebSocket call sites preserve the released runtime helper and subclass dispatch, using a distinct fallback only when that optional helper is absent. Dynamic lookup survives normal formatting and typechecks with both current and older runtimes; blank-value, repeated-key and mapping semantics remain covered. Queue bootstrap policy applies isolation to every verified constituent while retaining combined-budget enforcement and approval checks. Source fixes alone do not resolve candidate review feedback; the published SDK head must include and pass both fixes. -The snapshot owns 1,968 paths, including 1,797 source paths. Another 204 source files (35,158 physical lines) remain outside its ownership and are not mixed files. No mode-only or binary differences occur. The top six mixed files account for 5,173 patch lines (about 70%); finishing small tracks must lead into the larger client, audio, Assistants and parsing families. +Reproduce the private candidate report from an internal SDK checkout containing these commits: -### Current per-file inventory +```sh +python3 -I scripts/castiron/custom_code_report.py report \ + --repo . \ + --base 49e6757e464ce03b6f47c89740feaa75870ba168 \ + --head 5fcd05c9064e5bcfa820aed283d9570e1f997255 \ + --fetch --require-head-hash \ + --out /tmp/python-transition-candidate-report +``` + +## Current published baseline — 2026-10-22 + +Published baseline `49e6757e464ce03b6f47c89740feaa75870ba168` contains **48 mixed files; 7,565 additions + 1,608 deletions = 9,173 custom patch lines**, with **50,243 integrated physical lines versus 44,286 generated physical lines** in those files. The fixed October 19 audit below remains historical; `current_audit` in the JSON records this released baseline. The additive upload runtime (#4146) and polling runtime (#4148) are merged; their handwritten modules remain outside generated ownership. +Verified public snapshot: `f99cb5bb5fc87965058d7055e382a25d1d064bcc`; generation `dd7765a4-3f6a-4a04-aaca-19aaa7507cdd`; generated-content SHA-256 `a7aa886594dd13f43e180a8516000bdf96481ed147ba52f0f2def6e243bb03b5`. The reporter is byte-identical to this published revision (SHA-256 `6b7cfda1f85426daaa2e4f6186e2d1b20ac638dadaa54be8142ecec89fefd880`); `--public --fetch --require-head-hash` verifies the actual public checkpoint ancestry and content hash. Run the report from a clone whose `origin` is the public SDK repository; set both `--base` and `--head` to this baseline SHA to reproduce the current inventory. No synthetic tracking ref or checkpoint recovery is needed. + +Release 3.36.1 (#4145) changes only version/changelog metadata after #4151 and leaves every inventory row and total unchanged. This baseline includes the released service-account API-key documentation update in [#4151](https://github.com/openai/openai-python/pull/4151). Compared with `7cd702895379656bee62047ff64813ff616aeef7`, changed generated baselines add two mixed files: `scripts/castiron/custom_code_report.py` (206 patch lines) and `src/openai/types/__init__.py` (1 patch line). Existing mixed-file patches increase by another 903 lines: 1,110 additional measured patch lines overall. The SDK update itself changes documentation, while its generated checkpoint changes this measurement. The current 10,000-line budget has 827 lines of headroom. Preserve released behavior, exports and tests; measure the pending candidate separately before claiming a reduction. + +The snapshot owns 1,968 paths, including 1,797 source paths. Another 205 paths under `src/openai/` (35,728 physical lines) remain outside generated ownership and are not mixed files. Physical lines include comments and blanks. Additions plus deletions measure patch size, not executable handwritten statements removed. + +### Current per-file inventory | File | Integrated lines | Generated lines | Added | Deleted | Patch | Track | | --- | ---: | ---: | ---: | ---: | ---: | --- | | `src/openai/resources/beta/threads/runs/runs.py` | 3161 | 2052 | 1290 | 181 | 1471 | T4 | -| `src/openai/resources/responses/responses.py` | 5169 | 4323 | 920 | 74 | 994 | T5 | +| `src/openai/resources/responses/responses.py` | 5171 | 4295 | 986 | 110 | 1096 | T5 | | `src/openai/resources/audio/transcriptions.py` | 1089 | 1403 | 287 | 601 | 888 | T3 | | `src/openai/_client.py` | 2401 | 1702 | 759 | 60 | 819 | T2 | +| `scripts/castiron/test_custom_code_report.py` | 1081 | 460 | 621 | 0 | 621 | T0 | | `src/openai/resources/chat/completions/completions.py` | 3456 | 2854 | 603 | 1 | 604 | T6 | | `src/openai/resources/beta/threads/threads.py` | 2009 | 1626 | 390 | 7 | 397 | T4 | | `src/openai/resources/vector_stores/file_batches.py` | 867 | 628 | 242 | 3 | 245 | T7 | | `src/openai/resources/uploads/uploads.py` | 765 | 544 | 221 | 0 | 221 | T8 | | `src/openai/resources/vector_stores/files.py` | 1032 | 813 | 220 | 1 | 221 | T7 | +| `src/openai/resources/beta/responses/responses.py` | 5565 | 5535 | 122 | 92 | 214 | T5 | +| `scripts/castiron/custom_code_report.py` | 1250 | 1078 | 189 | 17 | 206 | T0 | | `src/openai/__init__.py` | 505 | 321 | 185 | 1 | 186 | T2 | +| `src/openai/resources/live/sideband.py` | 1413 | 1394 | 89 | 70 | 159 | T11 | | `src/openai/resources/videos.py` | 1457 | 1306 | 153 | 2 | 155 | T10 | +| `src/openai/resources/live/forks.py` | 1446 | 1417 | 85 | 56 | 141 | T11 | +| `src/openai/resources/realtime/translations/translations.py` | 1152 | 1094 | 98 | 40 | 138 | T11 | +| `src/openai/resources/realtime/realtime.py` | 1650 | 1624 | 80 | 54 | 134 | T11 | | `api.md` | 2090 | 1979 | 119 | 8 | 127 | T16 | | `src/openai/resources/beta/agents/environments/files.py` | 596 | 473 | 125 | 2 | 127 | T9 | +| `src/openai/resources/live/live.py` | 1643 | 1606 | 81 | 44 | 125 | T11 | | `src/openai/resources/beta/agents/sessions/sessions.py` | 1304 | 1202 | 113 | 11 | 124 | T9 | | `src/openai/resources/audio/translations.py` | 374 | 409 | 40 | 75 | 115 | T3 | -| `src/openai/resources/beta/responses/responses.py` | 5563 | 5563 | 55 | 55 | 110 | T5 | | `src/openai/resources/webhooks/webhooks.py` | 1146 | 1061 | 89 | 4 | 93 | T12 | | `tests/api_resources/test_videos.py` | 939 | 919 | 54 | 34 | 88 | T10 | | `src/openai/resources/webhooks/__init__.py` | 42 | 73 | 24 | 55 | 79 | T12 | +| `scripts/castiron/test_custom_code_budget.py` | 773 | 711 | 66 | 4 | 70 | T0 | +| `scripts/castiron/custom_code_budget.py` | 758 | 737 | 41 | 20 | 61 | T0 | | `src/openai/resources/embeddings.py` | 263 | 257 | 27 | 21 | 48 | T13 | -| `src/openai/resources/live/forks.py` | 1444 | 1447 | 15 | 18 | 33 | T11 | -| `src/openai/resources/live/sideband.py` | 1421 | 1424 | 15 | 18 | 33 | T11 | +| `scripts/castiron/test_custom_code_github.py` | 653 | 622 | 31 | 0 | 31 | T0 | +| `scripts/castiron/test_custom_code_publication.py` | 567 | 542 | 26 | 1 | 27 | T0 | | `src/openai/resources/files.py` | 826 | 800 | 26 | 0 | 26 | T14 | -| `scripts/castiron/custom_code_budget.py` | 758 | 755 | 14 | 11 | 25 | T0 | -| `scripts/castiron/test_custom_code_budget.py` | 773 | 764 | 15 | 6 | 21 | T0 | | `src/openai/resources/beta/beta.py` | 269 | 248 | 21 | 0 | 21 | T15 | | `src/openai/resources/realtime/api.md` | 149 | 169 | 0 | 20 | 20 | T16 | -| `src/openai/resources/realtime/translations/translations.py` | 1140 | 1124 | 18 | 2 | 20 | T11 | -| `src/openai/resources/live/live.py` | 1641 | 1636 | 11 | 6 | 17 | T11 | -| `src/openai/resources/realtime/realtime.py` | 1658 | 1652 | 9 | 3 | 12 | T11 | | `src/openai/types/decision_input_image_param.py` | 47 | 37 | 11 | 1 | 12 | T1 | | `src/openai/types/decision_input_text_param.py` | 33 | 23 | 11 | 1 | 12 | T1 | | `src/openai/resources/beta/agents/sessions/artifacts.py` | 561 | 551 | 10 | 0 | 10 | T9 | +| `tests/api_resources/admin/organization/projects/test_api_keys.py` | 442 | 432 | 10 | 0 | 10 | T1 | +| `scripts/castiron/fixtures/github_publisher.cjs` | 64 | 59 | 6 | 1 | 7 | T0 | | `src/openai/types/webhooks/__init__.py` | 73 | 71 | 3 | 1 | 4 | T1 | | `src/openai/types/webhooks/unwrap_webhook_event.py` | 74 | 74 | 2 | 2 | 4 | T1 | | `src/openai/types/responses/tool.py` | 398 | 399 | 1 | 2 | 3 | T1 | @@ -64,9 +160,7 @@ The snapshot owns 1,968 paths, including 1,797 source paths. Another 204 source | `src/openai/types/fine_tuning/fine_tuning_job_integration.py` | 5 | 7 | 0 | 2 | 2 | T1 | | `src/openai/types/realtime/conversation_item_input_audio_transcription_delta_event.py` | 40 | 40 | 1 | 1 | 2 | T1 | | `src/openai/types/realtime/realtime_error_event.py` | 25 | 25 | 1 | 1 | 2 | T1 | -| `scripts/castiron/test_custom_code_github.py` | 653 | 652 | 1 | 0 | 1 | T0 | - -Machine-readable evidence: [`castiron-transition-inventory.json`](castiron-transition-inventory.json), under `current_audit`. Reproduce using the pinned main reporter and command below with the current audit SHA for both revisions. +| `src/openai/types/__init__.py` | 150 | 149 | 1 | 0 | 1 | T2 | ## Baseline and measurement @@ -374,13 +468,13 @@ If one successor needs a human review, CI repair, or another merged prerequisite | T0 generated reporting restoration | Original cleanup merged; renewed formatting work remains | [#4113](https://github.com/openai/openai-python/pull/4113), head `886eb8079e65cfc0509fc945c17d7578b3459bfb`, merged as `9d12b565554c08bcee6287c0d8f671b8c97ed911` on main. Both files matched the verified generated snapshot byte for byte at that merge; its post-merge measurement was 39 mixed files / 7,351 patch lines, with all other customizations unchanged. Prior validation: 94 offline tests passed, one compiler-binary contract test skipped (binary unset); AST comparison and assertion mutation passed. Conductor verified every post-merge push check successful on the merge commit, including [run 39315896992](https://github.com/openai/openai-python/actions/runs/39315896992). | | T8 upload helper contract coverage | Merged; successor eligible | [#4112](https://github.com/openai/openai-python/pull/4112) merged on 2026-10-19 as `163a2966140bc0399af7d81e03580d9d907a51df`. Preserve its 16 sync/async contracts in the typed upload-delegation successor. The default 64 MiB boundary remains an additional coverage requirement. This test-only merge does not reduce mixed-file counts or approve a later upload migration. | | T10 video polling compatibility | Merged; successor eligible | [#4115](https://github.com/openai/openai-python/pull/4115) merged on 2026-10-19 as `969ae2c734a508ebc5e3370c9dc41bd15602a4a4`. Retain all 21 public-entrypoint sync/async contracts and the interval mutation evidence in the polling-family successor. This test-only merge does not reduce mixed-file counts. | -| T15 Beta resource alias generation | Open; conflicts repaired; fresh CI and human compliance review pending | [Delta 2566635](https://flow.apps.openai.org/deltas/openai/openai/2566635), head `440d29e065bfe80c8f92e7f1d9ca8a44aef6c6ab`; [build 8880002](https://buildkite.com/openai-mono/monorepo/builds/8880002) is pending after the master refresh and behavior-preserving module extractions required by file-size checks. Preserve exact sync/async cached-property behavior and collision guards. The recorded approval explicitly does not replace pending human compatibility/compliance review. Do not merge on the automated approval alone. | -| T11 Live queue generation | Open; conflicts repaired; fresh CI and required review pending | [Delta 2565610](https://flow.apps.openai.org/deltas/openai/openai/2565610), head `906ed06a153ad7ff40d0e79982213ac5572a43f0`; [build 8880008](https://buildkite.com/openai-mono/monorepo/builds/8880008) is pending after the master refresh and behavior-preserving module extractions required by file-size checks. Preserve the Live byte limit, direct constructors and caller-owned empty queues. Compiler and required human compliance reviews remain prerequisites. | +| T15 Beta resource alias generation | Compiler merged; candidate SDK integration verified | [Delta 2566635](https://flow.apps.openai.org/deltas/openai/openai/2566635) merged as `509f49c1`. Candidate #315 removes the 21-line Beta patch and preserves the existing sync/async cached-property contracts and collision guards. Public promotion and its normal checks remain. | +| T11 Live queue generation | Compiler merged; candidate SDK integration verified | [Delta 2565610](https://flow.apps.openai.org/deltas/openai/openai/2565610) is merged. Candidate #315 makes Live, forks and sideband byte-identical to generated output, retaining the byte limit, direct constructors and caller-owned empty queues. | | T1 authored schema documentation | Redundant candidate; merged source already present | [Delta 2565621](https://flow.apps.openai.org/deltas/openai/openai/2565621) duplicates the corrections already merged through [Delta 1490194](https://flow.apps.openai.org/deltas/openai/openai/1490194), commit `97ba353f886c5248912eb50a516cb1aab2fee4c6`. The duplicate is closed with supersession evidence. The generated/integrated SDK descriptions still differ at the current audit; verify input propagation and regenerate before claiming the two-file/four-line reduction. | -| T7 / T10 / T14 polling family | Existing batch open; conflicts repaired; fresh CI and required review pending | [Delta 2565776](https://flow.apps.openai.org/deltas/openai/openai/2565776), head `e6040d78cf8116173807c6fc94a591e3dd9241fa`; [build 8880006](https://buildkite.com/openai-mono/monorepo/builds/8880006) is pending after the master refresh and behavior-preserving module extractions required by file-size checks. The original Files change has expanded to Files, Videos and vector-store files/batches. Prior review feedback was repaired; current approval/compliance gates remain prerequisites. Preserve prepared runtime extraction, exact facade contracts and generated warning/signature coverage. | -| T8 / T9 upload and agent facades | Existing batch open; conflicts repaired; fresh CI and required review pending | [Delta 2568634](https://flow.apps.openai.org/deltas/openai/openai/2568634), head `1f4f2cafdac4ef6b504ba2e535174b705aa96acd`; [build 8880007](https://buildkite.com/openai-mono/monorepo/builds/8880007) is pending after the master refresh and behavior-preserving module extractions required by file-size checks. Existing repairs cover generated-name collisions, overload shadowing, child-resource forwarding and test discovery. Preserve synchronous factories on async resources, subclass dispatch, part ordering and cleanup. SDK runtime preparation exists in the original local workspace; recover it before constructing the integration candidate. Compiler merge and separate affected-SDK CODEOWNER review remain required. | -| T0 recurring reporting formatting | Compiler repair green and approved; protected merge pending | [Delta 2585899](https://flow.apps.openai.org/deltas/openai/openai/2585899), head `d7a85e0380b72dfa446bc61f2a5e23871341cf57`, canonicalizes the three shared templates and adds a formatter-convergence assertion on actual compiler output. AST identity, Ruff 0.16.8/0.16.9 convergence and a negative control against all three original templates pass. Local Bazel was blocked before target analysis by missing sparse-checkout dependencies; compiled-output integration passed [CI 8878534](https://buildkite.com/openai-mono/monorepo/builds/8878534). Two repository hooks with absent scripts were skipped; all other publication/security hooks remained enabled. The 47-line reduction is projected until compiler merge and verified SDK generation. No policy or behavior change. | -| T11 client WebSocket query composition | Compiler merged; SDK integration remains | [Delta 2585522](https://flow.apps.openai.org/deltas/openai/openai/2585522), head `d64f939e2c2081795eb5457016288548ea7b10ec`, merged as `2d362ee4f251079a7fc6ba607ef835d63f912ad2` after green CI, security review and approvals. Generate the query helper on both clients together with its callers. Coordinate the existing SDK integration before opening a duplicate successor. | +| T7 / T10 / T14 polling family | Compiler and runtime preparation merged; separate integration preview verified | [Delta 2565776](https://flow.apps.openai.org/deltas/openai/openai/2565776) merged as `7098c3c1a28c8ab3afebd9158a68518243e059ce`. The ten vector runtime exports and parity tests are released in [#4148](https://github.com/openai/openai-python/pull/4148), merge `7cd702895379656bee62047ff64813ff616aeef7`. Seven polling configuration paths remain excluded from #315. The isolated successor preview preserves all 112 public contracts and 16 exports and passes 303 tests, Ruff, Mypy and Pyright. Regenerate against the final predecessor, publish, and verify its authoritative inventory before claiming a reduction. | +| T8 / T9 upload and agent facades | Compiler and upload runtime merged; candidate SDK integration verified | [Delta 2568634](https://flow.apps.openai.org/deltas/openai/openai/2568634) merged as `14c8b579`; upload runtime preparation [#4146](https://github.com/openai/openai-python/pull/4146) merged as `2d58e828`. Candidate #315 clears uploads, environment files and session artifacts while retaining synchronous factories on async resources, subclass dispatch, lazy defaults, logger timing, part ordering and cleanup. Its focused suite includes the 64 MiB boundary. | +| T0 recurring reporting formatting | Candidate cleanup verified; public integration pending | [Delta 2585899](https://flow.apps.openai.org/deltas/openai/openai/2585899) supplies the canonical reporting templates. Follow-up [Delta 2588691](https://flow.apps.openai.org/deltas/openai/openai/2588691) merged as `697c482a7e50c9278c03d25b9d0862e2f2e5d71e`, preserving executable semantics while fixing shared-template formatting. [Delta 2591310](https://flow.apps.openai.org/deltas/openai/openai/2591310) also preserves the requested unittest import style through normal formatting. The current candidate clears 7 reporting files / 1,023 baseline patch lines, as itemized above. Budget and exclusions remain unchanged. | +| T11 client WebSocket query composition | Compiler fixes merged; regenerated candidate verified | [Delta 2589816](https://flow.apps.openai.org/deltas/openai/openai/2589816) preserves released query serialization; [Delta 2590767](https://flow.apps.openai.org/deltas/openai/openai/2590767) preserves runtime helper dispatch with a distinct older-runtime fallback and formatter-safe dynamic lookup. Candidate #315 retains client properties and the prepared translations query path. Current/older-runtime typing and released wire contracts must remain green through public promotion. Client and translations files remain mixed. | | Earlier stable web-search naming | Superseded; stale candidate closed | [Internal #55](https://github.com/openai/openai-python-internal/pull/55) closed after confirming merged [Delta 2513050](https://flow.apps.openai.org/deltas/openai/openai/2513050) and exact generated/integrated equality of both stable web-search modules on current main. Do not reintroduce its stale generation metadata or historical cross-language naming change. | The ledger reports observed work; projected reductions are not merged reductions. Refresh it with PR/Delta links and exact tested heads as publication completes. diff --git a/docs/castiron-transition-inventory.json b/docs/castiron-transition-inventory.json index d51cdeab8f..0d5cf5de0c 100644 --- a/docs/castiron-transition-inventory.json +++ b/docs/castiron-transition-inventory.json @@ -454,20 +454,20 @@ } ], "current_audit": { - "audit_date": "2026-10-21", - "sdk_commit": "b668ee88e7cb670704a0fd300152da29666b26cc", - "public_codegen_commit": "efa5c8db4dbf18180716aff72b9a58d1a9d6d2d6", - "generation_id": "d25554dd-0197-4996-acdc-7aade76e2045", - "codegen_hash": "7c1bfd93466328a0bae3a67e40c27251bc2152767cd7cf0c12835d7fac647efd", + "audit_date": "2026-10-22", + "sdk_commit": "49e6757e464ce03b6f47c89740feaa75870ba168", + "public_codegen_commit": "f99cb5bb5fc87965058d7055e382a25d1d064bcc", + "generation_id": "dd7765a4-3f6a-4a04-aaca-19aaa7507cdd", + "codegen_hash": "a7aa886594dd13f43e180a8516000bdf96481ed147ba52f0f2def6e243bb03b5", "reporter_sha256": "6b7cfda1f85426daaa2e4f6186e2d1b20ac638dadaa54be8142ecec89fefd880", "measurement": "generated-owned paths; git diff --numstat --no-renames; additions plus deletions; physical lines include comments and blanks", "totals": { - "mixed_files": 42, - "added_lines": 6106, - "deleted_lines": 1292, - "custom_patch_lines": 7398, - "current_physical_lines": 46685, - "generated_physical_lines": 41871 + "mixed_files": 48, + "added_lines": 7565, + "deleted_lines": 1608, + "custom_patch_lines": 9173, + "current_physical_lines": 50243, + "generated_physical_lines": 44286 }, "files": [ { @@ -482,12 +482,12 @@ }, { "path": "src/openai/resources/responses/responses.py", - "added": 920, - "removed": 74, - "patch": 994, + "added": 986, + "removed": 110, + "patch": 1096, "present": true, - "current_lines": 5169, - "generated_lines": 4323, + "current_lines": 5171, + "generated_lines": 4295, "track": "T5" }, { @@ -510,6 +510,16 @@ "generated_lines": 1702, "track": "T2" }, + { + "path": "scripts/castiron/test_custom_code_report.py", + "added": 621, + "removed": 0, + "patch": 621, + "present": true, + "current_lines": 1081, + "generated_lines": 460, + "track": "T0" + }, { "path": "src/openai/resources/chat/completions/completions.py", "added": 603, @@ -560,6 +570,26 @@ "generated_lines": 813, "track": "T7" }, + { + "path": "src/openai/resources/beta/responses/responses.py", + "added": 122, + "removed": 92, + "patch": 214, + "present": true, + "current_lines": 5565, + "generated_lines": 5535, + "track": "T5" + }, + { + "path": "scripts/castiron/custom_code_report.py", + "added": 189, + "removed": 17, + "patch": 206, + "present": true, + "current_lines": 1250, + "generated_lines": 1078, + "track": "T0" + }, { "path": "src/openai/__init__.py", "added": 185, @@ -570,6 +600,16 @@ "generated_lines": 321, "track": "T2" }, + { + "path": "src/openai/resources/live/sideband.py", + "added": 89, + "removed": 70, + "patch": 159, + "present": true, + "current_lines": 1413, + "generated_lines": 1394, + "track": "T11" + }, { "path": "src/openai/resources/videos.py", "added": 153, @@ -580,6 +620,36 @@ "generated_lines": 1306, "track": "T10" }, + { + "path": "src/openai/resources/live/forks.py", + "added": 85, + "removed": 56, + "patch": 141, + "present": true, + "current_lines": 1446, + "generated_lines": 1417, + "track": "T11" + }, + { + "path": "src/openai/resources/realtime/translations/translations.py", + "added": 98, + "removed": 40, + "patch": 138, + "present": true, + "current_lines": 1152, + "generated_lines": 1094, + "track": "T11" + }, + { + "path": "src/openai/resources/realtime/realtime.py", + "added": 80, + "removed": 54, + "patch": 134, + "present": true, + "current_lines": 1650, + "generated_lines": 1624, + "track": "T11" + }, { "path": "api.md", "added": 119, @@ -600,6 +670,16 @@ "generated_lines": 473, "track": "T9" }, + { + "path": "src/openai/resources/live/live.py", + "added": 81, + "removed": 44, + "patch": 125, + "present": true, + "current_lines": 1643, + "generated_lines": 1606, + "track": "T11" + }, { "path": "src/openai/resources/beta/agents/sessions/sessions.py", "added": 113, @@ -620,16 +700,6 @@ "generated_lines": 409, "track": "T3" }, - { - "path": "src/openai/resources/beta/responses/responses.py", - "added": 55, - "removed": 55, - "patch": 110, - "present": true, - "current_lines": 5563, - "generated_lines": 5563, - "track": "T5" - }, { "path": "src/openai/resources/webhooks/webhooks.py", "added": 89, @@ -660,6 +730,26 @@ "generated_lines": 73, "track": "T12" }, + { + "path": "scripts/castiron/test_custom_code_budget.py", + "added": 66, + "removed": 4, + "patch": 70, + "present": true, + "current_lines": 773, + "generated_lines": 711, + "track": "T0" + }, + { + "path": "scripts/castiron/custom_code_budget.py", + "added": 41, + "removed": 20, + "patch": 61, + "present": true, + "current_lines": 758, + "generated_lines": 737, + "track": "T0" + }, { "path": "src/openai/resources/embeddings.py", "added": 27, @@ -671,24 +761,24 @@ "track": "T13" }, { - "path": "src/openai/resources/live/forks.py", - "added": 15, - "removed": 18, - "patch": 33, + "path": "scripts/castiron/test_custom_code_github.py", + "added": 31, + "removed": 0, + "patch": 31, "present": true, - "current_lines": 1444, - "generated_lines": 1447, - "track": "T11" + "current_lines": 653, + "generated_lines": 622, + "track": "T0" }, { - "path": "src/openai/resources/live/sideband.py", - "added": 15, - "removed": 18, - "patch": 33, + "path": "scripts/castiron/test_custom_code_publication.py", + "added": 26, + "removed": 1, + "patch": 27, "present": true, - "current_lines": 1421, - "generated_lines": 1424, - "track": "T11" + "current_lines": 567, + "generated_lines": 542, + "track": "T0" }, { "path": "src/openai/resources/files.py", @@ -700,28 +790,6 @@ "generated_lines": 800, "track": "T14" }, - { - "path": "scripts/castiron/custom_code_budget.py", - "added": 14, - "removed": 11, - "patch": 25, - "present": true, - "current_lines": 758, - "generated_lines": 755, - "track": "T0", - "ast_equivalent": true - }, - { - "path": "scripts/castiron/test_custom_code_budget.py", - "added": 15, - "removed": 6, - "patch": 21, - "present": true, - "current_lines": 773, - "generated_lines": 764, - "track": "T0", - "ast_equivalent": true - }, { "path": "src/openai/resources/beta/beta.py", "added": 21, @@ -743,33 +811,377 @@ "track": "T16" }, { - "path": "src/openai/resources/realtime/translations/translations.py", - "added": 18, - "removed": 2, - "patch": 20, + "path": "src/openai/types/decision_input_image_param.py", + "added": 11, + "removed": 1, + "patch": 12, "present": true, - "current_lines": 1140, - "generated_lines": 1124, - "track": "T11" + "current_lines": 47, + "generated_lines": 37, + "track": "T1" }, { - "path": "src/openai/resources/live/live.py", + "path": "src/openai/types/decision_input_text_param.py", "added": 11, - "removed": 6, - "patch": 17, + "removed": 1, + "patch": 12, + "present": true, + "current_lines": 33, + "generated_lines": 23, + "track": "T1" + }, + { + "path": "src/openai/resources/beta/agents/sessions/artifacts.py", + "added": 10, + "removed": 0, + "patch": 10, + "present": true, + "current_lines": 561, + "generated_lines": 551, + "track": "T9" + }, + { + "path": "tests/api_resources/admin/organization/projects/test_api_keys.py", + "added": 10, + "removed": 0, + "patch": 10, + "present": true, + "current_lines": 442, + "generated_lines": 432, + "track": "T1" + }, + { + "path": "scripts/castiron/fixtures/github_publisher.cjs", + "added": 6, + "removed": 1, + "patch": 7, + "present": true, + "current_lines": 64, + "generated_lines": 59, + "track": "T0" + }, + { + "path": "src/openai/types/webhooks/__init__.py", + "added": 3, + "removed": 1, + "patch": 4, + "present": true, + "current_lines": 73, + "generated_lines": 71, + "track": "T1" + }, + { + "path": "src/openai/types/webhooks/unwrap_webhook_event.py", + "added": 2, + "removed": 2, + "patch": 4, + "present": true, + "current_lines": 74, + "generated_lines": 74, + "track": "T1" + }, + { + "path": "src/openai/types/responses/tool.py", + "added": 1, + "removed": 2, + "patch": 3, + "present": true, + "current_lines": 398, + "generated_lines": 399, + "track": "T1" + }, + { + "path": "src/openai/types/responses/tool_param.py", + "added": 3, + "removed": 0, + "patch": 3, + "present": true, + "current_lines": 398, + "generated_lines": 395, + "track": "T1" + }, + { + "path": "src/openai/types/webhooks/live_call_incoming_webhook_event.py", + "added": 2, + "removed": 1, + "patch": 3, + "present": true, + "current_lines": 71, + "generated_lines": 70, + "track": "T1" + }, + { + "path": "src/openai/types/fine_tuning/fine_tuning_job_integration.py", + "added": 0, + "removed": 2, + "patch": 2, + "present": true, + "current_lines": 5, + "generated_lines": 7, + "track": "T1" + }, + { + "path": "src/openai/types/realtime/conversation_item_input_audio_transcription_delta_event.py", + "added": 1, + "removed": 1, + "patch": 2, + "present": true, + "current_lines": 40, + "generated_lines": 40, + "track": "T1" + }, + { + "path": "src/openai/types/realtime/realtime_error_event.py", + "added": 1, + "removed": 1, + "patch": 2, + "present": true, + "current_lines": 25, + "generated_lines": 25, + "track": "T1" + }, + { + "path": "src/openai/types/__init__.py", + "added": 1, + "removed": 0, + "patch": 1, + "present": true, + "current_lines": 150, + "generated_lines": 149, + "track": "T2" + } + ], + "ownership": { + "generated_paths": 1968, + "generated_source_paths": 1797, + "excluded_source_paths": 205, + "excluded_source_lines": 35728 + } + }, + "pending_candidate_audit": { + "audit_date": "2026-10-22", + "sdk_commit": "5fcd05c9064e5bcfa820aed283d9570e1f997255", + "generation_id": "9fa1f60f-3df4-4d35-8b22-19dbbb6c80fb", + "codegen_hash": "f71f5997995e620542e459fbe3a23789b2fbacf139d5d0c805e79c456abd2f49", + "reporter_sha256": "6b7cfda1f85426daaa2e4f6186e2d1b20ac638dadaa54be8142ecec89fefd880", + "measurement": "Verified private generated checkpoint versus integrated candidate. Physical lines count all blob.splitlines(); patch lines count additions plus deletions, not executable handwritten statements.", + "totals": { + "added": 5632, + "removed": 1213, + "patch": 6845, + "current_lines": 37881, + "generated_lines": 33462, + "mixed_files": 32 + }, + "files": [ + { + "path": "src/openai/resources/beta/threads/runs/runs.py", + "added": 1290, + "removed": 181, + "patch": 1471, + "present": true, + "current_lines": 3161, + "generated_lines": 2052, + "track": "T4" + }, + { + "path": "src/openai/resources/responses/responses.py", + "added": 920, + "removed": 74, + "patch": 994, + "present": true, + "current_lines": 5181, + "generated_lines": 4335, + "track": "T5" + }, + { + "path": "src/openai/resources/audio/transcriptions.py", + "added": 287, + "removed": 601, + "patch": 888, + "present": true, + "current_lines": 1089, + "generated_lines": 1403, + "track": "T3" + }, + { + "path": "src/openai/_client.py", + "added": 759, + "removed": 60, + "patch": 819, + "present": true, + "current_lines": 2440, + "generated_lines": 1741, + "track": "T2" + }, + { + "path": "src/openai/resources/chat/completions/completions.py", + "added": 603, + "removed": 1, + "patch": 604, + "present": true, + "current_lines": 3456, + "generated_lines": 2854, + "track": "T6" + }, + { + "path": "src/openai/resources/beta/threads/threads.py", + "added": 390, + "removed": 7, + "patch": 397, + "present": true, + "current_lines": 2009, + "generated_lines": 1626, + "track": "T4" + }, + { + "path": "src/openai/resources/vector_stores/file_batches.py", + "added": 242, + "removed": 3, + "patch": 245, + "present": true, + "current_lines": 867, + "generated_lines": 628, + "track": "T7" + }, + { + "path": "src/openai/resources/vector_stores/files.py", + "added": 220, + "removed": 1, + "patch": 221, + "present": true, + "current_lines": 1032, + "generated_lines": 813, + "track": "T7" + }, + { + "path": "src/openai/__init__.py", + "added": 185, + "removed": 1, + "patch": 186, + "present": true, + "current_lines": 505, + "generated_lines": 321, + "track": "T2" + }, + { + "path": "src/openai/resources/videos.py", + "added": 153, + "removed": 2, + "patch": 155, + "present": true, + "current_lines": 1457, + "generated_lines": 1306, + "track": "T10" + }, + { + "path": "api.md", + "added": 119, + "removed": 8, + "patch": 127, + "present": true, + "current_lines": 2090, + "generated_lines": 1979, + "track": "T16" + }, + { + "path": "src/openai/resources/beta/agents/sessions/sessions.py", + "added": 113, + "removed": 11, + "patch": 124, + "present": true, + "current_lines": 1304, + "generated_lines": 1202, + "track": "T9" + }, + { + "path": "src/openai/resources/audio/translations.py", + "added": 40, + "removed": 75, + "patch": 115, + "present": true, + "current_lines": 374, + "generated_lines": 409, + "track": "T3" + }, + { + "path": "src/openai/resources/beta/responses/responses.py", + "added": 55, + "removed": 55, + "patch": 110, + "present": true, + "current_lines": 5575, + "generated_lines": 5575, + "track": "T5" + }, + { + "path": "src/openai/resources/webhooks/webhooks.py", + "added": 89, + "removed": 4, + "patch": 93, + "present": true, + "current_lines": 1146, + "generated_lines": 1061, + "track": "T12" + }, + { + "path": "src/openai/resources/webhooks/__init__.py", + "added": 24, + "removed": 55, + "patch": 79, + "present": true, + "current_lines": 42, + "generated_lines": 73, + "track": "T12" + }, + { + "path": "src/openai/resources/realtime/translations/translations.py", + "added": 34, + "removed": 18, + "patch": 52, "present": true, - "current_lines": 1641, - "generated_lines": 1636, + "current_lines": 1152, + "generated_lines": 1136, "track": "T11" }, + { + "path": "src/openai/resources/embeddings.py", + "added": 27, + "removed": 21, + "patch": 48, + "present": true, + "current_lines": 263, + "generated_lines": 257, + "track": "T13" + }, + { + "path": "src/openai/resources/files.py", + "added": 26, + "removed": 0, + "patch": 26, + "present": true, + "current_lines": 826, + "generated_lines": 800, + "track": "T14" + }, + { + "path": "src/openai/resources/realtime/api.md", + "added": 0, + "removed": 20, + "patch": 20, + "present": true, + "current_lines": 149, + "generated_lines": 169, + "track": "T16" + }, { "path": "src/openai/resources/realtime/realtime.py", "added": 9, "removed": 3, "patch": 12, "present": true, - "current_lines": 1658, - "generated_lines": 1652, + "current_lines": 1660, + "generated_lines": 1654, "track": "T11" }, { @@ -793,14 +1205,14 @@ "track": "T1" }, { - "path": "src/openai/resources/beta/agents/sessions/artifacts.py", - "added": 10, + "path": "tests/api_resources/test_videos.py", + "added": 12, "removed": 0, - "patch": 10, + "patch": 12, "present": true, - "current_lines": 561, - "generated_lines": 551, - "track": "T9" + "current_lines": 939, + "generated_lines": 927, + "track": "T10" }, { "path": "src/openai/types/webhooks/__init__.py", @@ -881,33 +1293,154 @@ "current_lines": 25, "generated_lines": 25, "track": "T1" - }, - { - "path": "scripts/castiron/test_custom_code_github.py", - "added": 1, - "removed": 0, - "patch": 1, - "present": true, - "current_lines": 653, - "generated_lines": 652, - "track": "T0", - "ast_equivalent": true } ], "ownership": { - "generated_paths": 1968, + "generated_paths": 1970, "generated_source_paths": 1797, - "excluded_source_paths": 204, - "excluded_source_lines": 35158 + "excluded_source_paths": 205, + "excluded_source_lines": 35731 }, - "changes_from_original": { - "resolved": [ - "scripts/castiron/test_custom_code_report.py" - ], - "new": [ - "scripts/castiron/test_custom_code_budget.py", - "scripts/castiron/test_custom_code_github.py" - ] - } + "private_codegen_commit": "b345a3bfad1729f1640878441f304e9fc4787c36", + "status": "private candidate; not a merged public reduction", + "pull_request": "https://github.com/openai/openai-python-internal/pull/315", + "base_sdk_commit": "49e6757e464ce03b6f47c89740feaa75870ba168", + "compiler_commit": "6959b4685e426934700ebb40c159e382714c8542", + "verification": { + "public": false, + "fetch": true, + "require_head_hash": true, + "counts": { + "after": 32, + "baseline_changed": 22, + "before": 48, + "unchanged": 26 + } + }, + "removed_files": [ + { + "path": "scripts/castiron/custom_code_budget.py", + "previous_patch": 61, + "current_lines": 702, + "generated_lines": 702 + }, + { + "path": "scripts/castiron/custom_code_report.py", + "previous_patch": 206, + "current_lines": 1250, + "generated_lines": 1250 + }, + { + "path": "scripts/castiron/fixtures/github_publisher.cjs", + "previous_patch": 7, + "current_lines": 69, + "generated_lines": 69 + }, + { + "path": "scripts/castiron/test_custom_code_budget.py", + "previous_patch": 70, + "current_lines": 773, + "generated_lines": 773 + }, + { + "path": "scripts/castiron/test_custom_code_github.py", + "previous_patch": 31, + "current_lines": 978, + "generated_lines": 978 + }, + { + "path": "scripts/castiron/test_custom_code_publication.py", + "previous_patch": 27, + "current_lines": 873, + "generated_lines": 873 + }, + { + "path": "scripts/castiron/test_custom_code_report.py", + "previous_patch": 621, + "current_lines": 1084, + "generated_lines": 1084 + }, + { + "path": "src/openai/resources/beta/agents/environments/files.py", + "previous_patch": 127, + "current_lines": 604, + "generated_lines": 604 + }, + { + "path": "src/openai/resources/beta/agents/sessions/artifacts.py", + "previous_patch": 10, + "current_lines": 573, + "generated_lines": 573 + }, + { + "path": "src/openai/resources/beta/beta.py", + "previous_patch": 21, + "current_lines": 266, + "generated_lines": 266 + }, + { + "path": "src/openai/resources/live/forks.py", + "previous_patch": 141, + "current_lines": 1460, + "generated_lines": 1460 + }, + { + "path": "src/openai/resources/live/live.py", + "previous_patch": 125, + "current_lines": 1649, + "generated_lines": 1649 + }, + { + "path": "src/openai/resources/live/sideband.py", + "previous_patch": 159, + "current_lines": 1427, + "generated_lines": 1427 + }, + { + "path": "src/openai/resources/uploads/uploads.py", + "previous_patch": 221, + "current_lines": 652, + "generated_lines": 652 + }, + { + "path": "src/openai/types/__init__.py", + "previous_patch": 1, + "current_lines": 150, + "generated_lines": 150 + }, + { + "path": "tests/api_resources/admin/organization/projects/test_api_keys.py", + "previous_patch": 10, + "current_lines": 442, + "generated_lines": 442 + } + ], + "changed_remaining_patches": [ + { + "path": "src/openai/resources/beta/responses/responses.py", + "before": 214, + "after": 110 + }, + { + "path": "src/openai/resources/realtime/realtime.py", + "before": 134, + "after": 12 + }, + { + "path": "src/openai/resources/realtime/translations/translations.py", + "before": 138, + "after": 52 + }, + { + "path": "src/openai/resources/responses/responses.py", + "before": 1096, + "after": 994 + }, + { + "path": "tests/api_resources/test_videos.py", + "before": 88, + "after": 12 + } + ] } } diff --git a/docs/realtime-translation.md b/docs/realtime-translation.md index 37f6a87e85..5e967ceffe 100644 --- a/docs/realtime-translation.md +++ b/docs/realtime-translation.md @@ -11,13 +11,13 @@ from openai import OpenAI from openai.lib.realtime_translation import TranslationSession with OpenAI() as client: - with TranslationSession( - client.realtime.translations, model="gpt-realtime-translate" - ) as connection: - connection.send({ - "type": "session.input_audio_buffer.append", - "audio": base64.b64encode(pcm_audio).decode("ascii"), - }) + with TranslationSession(client.realtime.translations, model="gpt-realtime-translate") as connection: + connection.send( + { + "type": "session.input_audio_buffer.append", + "audio": base64.b64encode(pcm_audio).decode("ascii"), + } + ) connection.send({"type": "session.close"}) while True: event = connection.recv() diff --git a/helpers.md b/helpers.md index b3d53ccd74..c979675254 100644 --- a/helpers.md +++ b/helpers.md @@ -15,14 +15,17 @@ from typing import List from pydantic import BaseModel from openai import OpenAI + class Step(BaseModel): explanation: str output: str + class MathResponse(BaseModel): steps: List[Step] final_answer: str + client = OpenAI() completion = client.chat.completions.parse( model="gpt-4o-2024-08-06", @@ -75,11 +78,13 @@ from typing import List, Union from pydantic import BaseModel import openai + class Table(str, Enum): orders = "orders" customers = "customers" products = "products" + class Column(str, Enum): id = "id" status = "status" @@ -89,6 +94,7 @@ class Column(str, Enum): ordered_at = "ordered_at" canceled_at = "canceled_at" + class Operator(str, Enum): eq = "=" gt = ">" @@ -97,24 +103,29 @@ class Operator(str, Enum): ge = ">=" ne = "!=" + class OrderBy(str, Enum): asc = "asc" desc = "desc" + class DynamicValue(BaseModel): column_name: str + class Condition(BaseModel): column: str operator: Operator value: Union[str, int, DynamicValue] + class Query(BaseModel): table_name: Table columns: List[Column] conditions: List[Condition] order_by: OrderBy + client = openai.OpenAI() completion = client.chat.completions.parse( model="gpt-4o-2024-08-06", @@ -164,12 +175,12 @@ from openai import AsyncOpenAI client = AsyncOpenAI() async with client.chat.completions.stream( - model='gpt-4o-2024-08-06', + model="gpt-4o-2024-08-06", messages=[...], ) as stream: async for event in stream: - if event.type == 'content.delta': - print(event.delta, flush=True, end='') + if event.type == "content.delta": + print(event.delta, flush=True, end="") ``` When the context manager is entered, a `ChatCompletionStream` / `AsyncChatCompletionStream` instance is returned which, like `.create(stream=True)` is an iterator in the sync client and an async iterator in the async client. The full list of events that are yielded by the iterator are outlined [below](#chat-completions-events). @@ -322,40 +333,42 @@ client = openai.OpenAI() # First, we create a EventHandler class to define # how we want to handle the events in the response stream. + class EventHandler(AssistantEventHandler): - @override - def on_text_created(self, text: Text) -> None: - print(f"\nassistant > ", end="", flush=True) - - @override - def on_text_delta(self, delta: TextDelta, snapshot: Text): - print(delta.value, end="", flush=True) - - @override - def on_tool_call_created(self, tool_call: ToolCall): - print(f"\nassistant > {tool_call.type}\n", flush=True) - - @override - def on_tool_call_delta(self, delta: ToolCallDelta, snapshot: ToolCall): - if delta.type == "code_interpreter" and delta.code_interpreter: - if delta.code_interpreter.input: - print(delta.code_interpreter.input, end="", flush=True) - if delta.code_interpreter.outputs: - print(f"\n\noutput >", flush=True) - for output in delta.code_interpreter.outputs: - if output.type == "logs": - print(f"\n{output.logs}", flush=True) + @override + def on_text_created(self, text: Text) -> None: + print(f"\nassistant > ", end="", flush=True) + + @override + def on_text_delta(self, delta: TextDelta, snapshot: Text): + print(delta.value, end="", flush=True) + + @override + def on_tool_call_created(self, tool_call: ToolCall): + print(f"\nassistant > {tool_call.type}\n", flush=True) + + @override + def on_tool_call_delta(self, delta: ToolCallDelta, snapshot: ToolCall): + if delta.type == "code_interpreter" and delta.code_interpreter: + if delta.code_interpreter.input: + print(delta.code_interpreter.input, end="", flush=True) + if delta.code_interpreter.outputs: + print(f"\n\noutput >", flush=True) + for output in delta.code_interpreter.outputs: + if output.type == "logs": + print(f"\n{output.logs}", flush=True) + # Then, we use the `stream` SDK helper # with the `EventHandler` class to create the Run # and stream the response. with client.beta.threads.runs.stream( - thread_id="thread_id", - assistant_id="assistant_id", - event_handler=EventHandler(), + thread_id="thread_id", + assistant_id="assistant_id", + event_handler=EventHandler(), ) as stream: - stream.until_done() + stream.until_done() ``` #### An example of iterating over events @@ -363,10 +376,7 @@ with client.beta.threads.runs.stream( You can also iterate over all the streamed events. ```python -with client.beta.threads.runs.stream( - thread_id=thread.id, - assistant_id=assistant.id -) as stream: +with client.beta.threads.runs.stream(thread_id=thread.id, assistant_id=assistant.id) as stream: for event in stream: # Print the text from text delta events if event.event == "thread.message.delta" and event.data.delta.content: @@ -378,10 +388,7 @@ with client.beta.threads.runs.stream( You can also iterate over just the text deltas received ```python -with client.beta.threads.runs.stream( - thread_id=thread.id, - assistant_id=assistant.id -) as stream: +with client.beta.threads.runs.stream(thread_id=thread.id, assistant_id=assistant.id) as stream: for text in stream.text_deltas: print(text) ``` @@ -579,7 +586,8 @@ that retains Pydantic argument parsing in both `responses.parse()` and ```python tool = openai.pydantic_responses_function_tool(LookupItem, defer_loading=True) response = client.responses.parse( - model=MODEL, input="Find catalog item A123.", + model=MODEL, + input="Find catalog item A123.", tools=[{"type": "tool_search"}, tool], ) for item in response.output: @@ -597,14 +605,18 @@ Bind an annotated function or bound method once, then reuse its definition and l ```py from openai.lib.beta.agents import function_tool + @function_tool(name="lookup_item", description="Look up a catalog item.") def lookup(item_id: str) -> dict[str, str]: return {"item_id": item_id, "name": "Notebook"} + with client.beta.agents.sessions.create( agent={"model": MODEL, "tools": [lookup.definition]}, - environment={"type": "none"}, input="Find catalog item A123.", - stream=True, tool_handlers={lookup.name: lookup}, + environment={"type": "none"}, + input="Find catalog item A123.", + stream=True, + tool_handlers={lookup.name: lookup}, ) as stream: print(stream.get_final_result().output_text) ``` @@ -615,11 +627,15 @@ For an existing Pydantic argument model, use an explicit binding: from pydantic import BaseModel from openai.lib.beta.agents import pydantic_function_tool + class LookupArguments(BaseModel): item_id: str + lookup = pydantic_function_tool( - LookupArguments, name="lookup_item", handler=catalog.lookup, + LookupArguments, + name="lookup_item", + handler=catalog.lookup, ) # catalog.lookup receives a validated LookupArguments instance. ``` @@ -638,6 +654,7 @@ def lookup_item(item_id: str) -> str: """Look up a catalog item.""" return catalog.lookup(item_id) + tools = [{"type": "tool_search"}, lookup_item.definition] handlers = {lookup_item.name: lookup_item} ``` @@ -655,11 +672,16 @@ application’s redaction policy before logging them. def report_tool_error(failure): logger.error( "Tool %s failed during %s (call %s)", - failure.tool_name, failure.stage, failure.call_id, + failure.tool_name, + failure.stage, + failure.call_id, ) + with client.beta.agents.sessions.stream( - SESSION_ID, input="Find catalog item A123.", tool_handlers=handlers, + SESSION_ID, + input="Find catalog item A123.", + tool_handlers=handlers, on_tool_error=report_tool_error, ) as stream: stream.until_done() @@ -678,13 +700,18 @@ Responses; the API validates which schema features it supports. ```python from pydantic import BaseModel + class Report(BaseModel): summary: str findings: list[str] + with client.beta.agents.sessions.create( - agent={"model": MODEL}, environment={"type": "none"}, - input="Summarize the findings.", stream=True, output_type=Report, + agent={"model": MODEL}, + environment={"type": "none"}, + input="Summarize the findings.", + stream=True, + output_type=Report, ) as stream: result = stream.get_final_result() print(result.output_parsed) @@ -703,9 +730,11 @@ creation and the result getter, and use `async with`. ```python from pathlib import Path -prepared = client.beta.agents.environments.files.prepare({ - "/workspace/source.pdf": Path("source.pdf"), -}) +prepared = client.beta.agents.environments.files.prepare( + { + "/workspace/source.pdf": Path("source.pdf"), + } +) session = client.beta.agents.sessions.create( agent={"model": MODEL}, environment={"type": "openai_hosted", "files": prepared.files}, @@ -719,9 +748,7 @@ artifacts = client.beta.agents.sessions.artifacts.for_result(result) # Read in memory, or stream to an application-owned local path. report_bytes = artifacts.content("/workspace/outputs/report.md").content -artifact = artifacts.download( - "/workspace/outputs/report.md", to=Path("downloaded-report.md") -) +artifact = artifacts.download("/workspace/outputs/report.md", to=Path("downloaded-report.md")) ``` Use `prepare_directory("docs", destination="/workspace/docs", include=["**/*.md"])` diff --git a/scripts/castiron/CUSTOM_CODE.md b/scripts/castiron/CUSTOM_CODE.md index 7ef3e63c59..5ee61c8bf2 100644 --- a/scripts/castiron/CUSTOM_CODE.md +++ b/scripts/castiron/CUSTOM_CODE.md @@ -78,7 +78,10 @@ The custom-code workflow pair separates candidate execution from trusted checks: - `castiron-custom-code.yml` runs proposed offline tests and the advisory report on `pull_request` with read-only permissions. - `castiron-custom-code-comment.yml` handles `workflow_run` from **main**. Its - read-only compute job pins executable checker code and budget policy to main. + read-only selector authenticates the target base and selects a main commit. + Main uses its captured current commit. Next uses the reviewed `next_policy` + version stored in its protected target base. A second checkout executes only + that authenticated main version of the reporter/checker and reads its policy. It independently resolves the live PR target (`main` or `next`) and captures the target baseline and candidate SHA. For main, the target baseline is the pinned main checkout; for next, it is the live next ref, not stale PR metadata. @@ -96,21 +99,23 @@ merge groups targeting `main`, after rechecking the PR head/target or queue base - `Castiron / budget-only change` - `Castiron / custom-code budget` -These contexts are reserved for `main`. Reports for `next` are advisory and -never write required statuses, even when both targets use the same commit. -GitHub does not rebuild the `next` queue when policy on `main` changes, so a -captured `next` result cannot safely authorize a merge against current policy. +Next uses separate `Castiron next / budget-only change` and +`Castiron next / custom-code budget` contexts, so identical commit SHAs on the +two tracks cannot overwrite each other's authorization. The policy version is +fixed by next's protected base; advancing main does not silently change it. +Changing the reviewed pin changes next's own base and rebuilds its merge queue. -The policy is read from the captured main commit, not next, the PR, or its merge -base. Target baseline, trusted policy, and candidate identities are recorded +The effective policy is read from the selected authenticated main commit. The +next base selects that version; a candidate never selects its own executing +checker or grants itself a larger budget. Target baseline, trusted policy, and candidate identities are recorded separately in the report and budget result. Reusing a report requires matching branch, policy, and head identities; retargeted PRs fail closed. PR results describe a captured snapshot even if a branch moves during computation. They do not authorize merging without queue validation. Queue checks require -current main policy and current target baseline. Immediately before publishing, -the handler rechecks the PR head/target or queue membership, target baseline, -and main policy. A stale queue evaluation cannot publish success. Missing +current target baseline and its approved policy version (current main for main, +reviewed pin for next). Immediately before publishing, the handler rechecks the +PR head/target or queue membership and the bound target baseline/policy identities. A stale queue evaluation cannot publish success. Missing snapshots, invalid hashes, unavailable membership, and policy errors fail closed. The queue signal cannot supply a passing result, and removing it leaves required @@ -171,12 +176,42 @@ added later. ## Activating next Land shared tooling in Castiron first, then generate and review its SDK update -onto main. Keep required status activation separate until hosted tests have -exercised main and next PRs, forks, retargeting, policy changes, and both merge -queues. Do not require the `main` status contexts on `next`. Authoritative next -checks need separate contexts and a mechanism that keeps queue authorization -bound to current main policy, including changes after a result is published. -Until that mechanism is implemented and reviewed, next reports remain advisory. -Preserve its reviews, CODEOWNERS, merge queue, and all existing main protections. -This checker support does not enable generation or promotion to next, or releases -from next. Those require separate reviewed implementation. +onto main. Add a reviewed policy pin in a separate `.castiron-ratchet.json`-only +PR targeting next; preserve the existing budget/schema fields: + +```json +"next_policy": { + "repository": "openai/your-sdk", + "main_sha": "" +} +``` + +The SHA must belong to that same repository's authenticated main first-parent +history and support this pin protocol. Selection reads the protected target +base, never the candidate. Updates require an exact-head human approving review +from a repository owner, member, or collaborator, in addition to ordinary +CODEOWNERS and required reviews. Bot reviews, review-body markers, stale reviews, +and dismissed approvals do not authorize activation. + +For initial adoption only, a base with no pin may accept an exact policy-only +pin-initialization PR under authenticated current-main checker/policy, with the +same human review requirement. The proposed pin is validated but never executed +for its own initialization. A malformed base pin cannot enter this path; other +changes without a base pin fail closed. Ordinary pin updates execute under the +previous approved version and cannot include SDK/tooling changes. + +Keep required status activation separate until hosted tests have exercised main +and next PRs, forks, retargeting, policy changes, and both merge queues. Require +the target-specific next contexts after observing their genuine producers; +never reuse main's context names on next. Establish missing ordinary CI triggers +through a reviewed trusted workflow path before the policy-only initialization. +Preserve reviews, CODEOWNERS, merge queues, and all existing main protections. +This policy contract does not enable package releases from next. + +For `next` policy activations and budget increases, the trusted computation also +records the exact constituent PR numbers and head SHAs requiring human approval. +The isolated status publisher rechecks those live PRs and their latest reviews +immediately before publication. Dismissed, replaced, stale, automated or missing +approvals fail closed even if ordinary automated approvals keep the queue live. +The publisher consumes the selector's versioned review rules, with cross-runtime +predicate fixtures; candidate artifacts never supply authorization or outcomes. diff --git a/scripts/castiron/custom_code_budget.py b/scripts/castiron/custom_code_budget.py index 2248a47a64..9ead30bc43 100755 --- a/scripts/castiron/custom_code_budget.py +++ b/scripts/castiron/custom_code_budget.py @@ -20,61 +20,28 @@ from pathlib import Path from typing import Any, cast -# Explicitly load the reporter beside this trusted script, including under python -I. -# The inspected repository is only a Git object store, not a Python import path. +# Load only the policy helpers beside this immutable checker, including under -I. _spec = importlib.util.spec_from_file_location( - "castiron_budget_reporter", Path(__file__).with_name("custom_code_report.py") + "castiron_budget_policy", Path(__file__).with_name("custom_code_policy.py") ) assert _spec is not None and _spec.loader is not None -report = importlib.util.module_from_spec(_spec) -sys.modules[_spec.name] = report -_spec.loader.exec_module(report) +policy = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = policy +_spec.loader.exec_module(policy) +report = policy.report +unique_keys = policy.unique_keys +read_policy = policy.read_policy +changed_paths = policy.changed_paths +queued_entries = policy.queued_entries POLICY = ".castiron-ratchet.json" +NEXT_POLICY_VERSION = 1 ISOLATION_MESSAGE = ( "Budget changes require a separate, budget-only PR. " "Put the justification in the PR description." ) -def unique_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - value: dict[str, Any] = {} - for key, item in pairs: - if key in value: - raise ValueError(f"duplicate policy key: {key}") - value[key] = item - return value - - -def read_policy(repo: Path, revision: str) -> dict[str, Any]: - revision = report.require_sha(revision) - entry = report.git(repo, "ls-tree", revision, "--", POLICY).split() - if len(entry) != 4 or entry[:2] != [b"100644", b"blob"]: - raise ValueError(f"{POLICY} must exist as a regular, non-executable file at {revision}") - if int(report.git(repo, "cat-file", "-s", entry[2].decode())) > 4096: - raise ValueError("budget file exceeds 4096 bytes") - value = json.loads( - report.git(repo, "show", f"{revision}:{POLICY}"), object_pairs_hook=unique_keys - ) - if not isinstance(value, dict): - raise ValueError("budget file must be an object") # noqa: TRY004 - malformed policy is a value error - value = cast(dict[str, Any], value) - version = value.get("schema_version") - if type(version) is not int or version not in (1, 2): - raise ValueError("unsupported budget schema_version") - keys = {"schema_version", "max_custom_patch_lines"} - if version == 2: - keys.add("generated_files") - if set(value) != keys: - raise ValueError("budget file contains missing or unsupported fields") - limit = value["max_custom_patch_lines"] - if type(limit) is not int or limit < 0: - raise ValueError("max_custom_patch_lines must be a nonnegative integer") - if version == 2 and (value["generated_files"] != "strict" or limit != 0): - raise ValueError("schema v2 requires generated_files=strict and a zero budget") - return value - - def read_budget(repo: Path, revision: str) -> int: return cast(int, read_policy(repo, revision)["max_custom_patch_lines"]) @@ -291,29 +258,6 @@ def verify_generated( return failures -def changed_paths(repo: Path, base: str, head: str) -> set[bytes]: - """Use the entire PR diff, not its last commit or a truncated API file list.""" - start = ( - report.git(repo, "merge-base", report.require_sha(base), report.require_sha(head)) - .decode() - .strip() - ) - return set( - report.git( - repo, - "diff", - "--name-only", - "-z", - "--no-renames", - "--no-ext-diff", - "--no-textconv", - start, - head, - "--", - ).split(b"\0") - ) - {b""} - - def check_isolation(repo: Path, base: str, head: str) -> int | None: # Validate even if the policy was removed by an earlier commit in this PR. proposed = read_budget(repo, head) @@ -477,52 +421,13 @@ def write_result(out: Path, result: dict[str, Any], patch: bytes = b"") -> None: print(summary) -def queued_entries(repository: str, branch: str) -> list[tuple[str, str]]: - """Query the actual queue; never infer PR membership from commit messages.""" - owner, name = repository.split("/") - query = """query($owner:String!,$name:String!,$branch:String!,$cursor:String) { - repository(owner:$owner,name:$name) { mergeQueue(branch:$branch) { - entries(first:100,after:$cursor) { - nodes { pullRequest { headRefOid } headCommit { oid } } - pageInfo { hasNextPage endCursor } - } - } } - }""" - cursor = None - heads: list[tuple[str, str]] = [] - while True: - response = report.api( - "POST", - "graphql", - { - "query": query, - "variables": {"owner": owner, "name": name, "branch": branch, "cursor": cursor}, - }, - ) - entries = response["data"]["repository"]["mergeQueue"]["entries"] - for node in entries["nodes"]: - # Entries still awaiting preparation have no synthetic queue commit. - if node["headCommit"] is not None: - heads.append( - ( - report.require_sha(node["pullRequest"]["headRefOid"]), - report.require_sha(node["headCommit"]["oid"]), - ) - ) - page = entries["pageInfo"] - if not page["hasNextPage"]: - return heads - if not page["endCursor"] or page["endCursor"] == cursor: - raise ValueError("invalid merge-queue pagination") - cursor = page["endCursor"] - - def github_evaluate( repo: Path, repository: str, event: dict[str, Any], trusted_sha: str, trusted_report_dir: Path | None = None, + selection: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], bytes]: """Fetch data into a fresh bare repo; the executable checkout remains trusted.""" if not report.REPOSITORY.fullmatch(repository): @@ -584,7 +489,7 @@ def github_evaluate( current_main = report.require_sha( report.api("GET", f"{root}/git/ref/heads/main")["object"]["sha"] ) - if main != current_main: + if branch == "main" and main != current_main: raise ValueError("trusted checkout is stale; rerun against current main") base = ( main @@ -607,6 +512,23 @@ def github_evaluate( f"{branch} must require a merge queue before activating the budget gate" ) page += 1 + # This receipt is created locally by the trusted-main selector before the + # pinned checkout. It must never come from candidate workflow artifacts. + if ( + selection is None + or type(selection.get("schema_version")) is not int + or any( + selection.get(key) != value + for key, value in { + "schema_version": 1, + "target_branch": branch, + "base_sha": base, + "head_sha": head, + "policy_sha": main, + }.items() + ) + ): + raise ValueError("trusted policy selection does not match evaluation") measurement = None if trusted_report_dir is not None: # Only the preceding main-branch step may supply this directory. Never @@ -649,6 +571,22 @@ def github_evaluate( pull_heads.append(candidate) if not pull_heads: raise ValueError("cannot verify merge-group PR membership; retry the current group") + if branch == "next": + assert selection is not None + base_pin = read_policy(repo, base).get("next_policy") + if base_pin is None: + # evaluate() checks each verified queue constituent for policy isolation. + if ( + selection.get("bootstrap") is not True + or "next_policy" not in read_policy(repo, head) + or (pull_heads is None and changed_paths(repo, base, head) != {POLICY.encode()}) + ): + raise ValueError("unreviewed next policy initialization") + elif selection.get("bootstrap") is not False or base_pin != { + "repository": repository, + "main_sha": main, + }: + raise ValueError("trusted next base pin changed; rerun policy selection") result, patch = evaluate( repo, base, @@ -665,6 +603,8 @@ def github_evaluate( def main() -> int: + if sys.argv[1:2] == ["select-policy"]: + return cast(int, policy.main(sys.argv[1:])) parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) generated = commands.add_parser("verify-generated") @@ -685,6 +625,7 @@ def main() -> int: github.add_argument("--event-path", type=Path, required=True) github.add_argument("--trusted-sha", required=True) github.add_argument("--trusted-report-dir", type=Path) + github.add_argument("--selected-policy-json", type=Path) for command in (local, github): command.add_argument("--repo", type=Path, required=True) command.add_argument("--out", type=Path, required=True) @@ -728,6 +669,9 @@ def main() -> int: json.loads(args.event_path.read_text()), args.trusted_sha, args.trusted_report_dir, + json.loads(args.selected_policy_json.read_text()) + if args.selected_policy_json + else None, ) except ( report.ReportError, diff --git a/scripts/castiron/custom_code_policy.py b/scripts/castiron/custom_code_policy.py new file mode 100755 index 0000000000..e2c1288f47 --- /dev/null +++ b/scripts/castiron/custom_code_policy.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +# File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. +"""Select reviewed policy using current main; emit a local, versioned receipt. + +The pinned budget evaluator remains a separate executable authority. This module +never executes candidate code, and receipts must not come from candidate artifacts. +""" + +from __future__ import annotations + +import argparse +import ast +import importlib.util +import json +import os +import re +import sys +from pathlib import Path +from typing import Any, TypedDict, cast + +# Explicitly load the reporter beside this trusted script, including under python -I. +# The inspected repository is only a Git object store, not a Python import path. +_spec = importlib.util.spec_from_file_location( + "castiron_budget_reporter", Path(__file__).with_name("custom_code_report.py") +) +assert _spec is not None and _spec.loader is not None +report = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = report +_spec.loader.exec_module(report) + +POLICY = ".castiron-ratchet.json" +NEXT_POLICY_VERSION = 1 +SELECTION_RECEIPT_VERSION = 1 + + +def unique_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError(f"duplicate policy key: {key}") + value[key] = item + return value + + +def read_policy(repo: Path, revision: str) -> dict[str, Any]: + revision = report.require_sha(revision) + entry = report.git(repo, "ls-tree", revision, "--", POLICY).split() + if len(entry) != 4 or entry[:2] != [b"100644", b"blob"]: + raise ValueError(f"{POLICY} must exist as a regular, non-executable file at {revision}") + if int(report.git(repo, "cat-file", "-s", entry[2].decode())) > 4096: + raise ValueError("budget file exceeds 4096 bytes") + value = json.loads( + report.git(repo, "show", f"{revision}:{POLICY}"), object_pairs_hook=unique_keys + ) + if not isinstance(value, dict): + raise ValueError("budget file must be an object") # noqa: TRY004 - malformed policy is a value error + value = cast(dict[str, Any], value) + version = value.get("schema_version") + if type(version) is not int or version not in (1, 2): + raise ValueError("unsupported budget schema_version") + keys = {"schema_version", "max_custom_patch_lines"} + if version == 2: + keys.add("generated_files") + if "next_policy" in value: + keys.add("next_policy") + pin: Any = value["next_policy"] + if not isinstance(pin, dict) or set(cast(dict[str, Any], pin)) != { + "repository", + "main_sha", + }: + raise ValueError("next_policy must contain exactly repository and main_sha") + if not isinstance(pin["repository"], str) or not report.REPOSITORY.fullmatch( + pin["repository"] + ): + raise ValueError("invalid next_policy repository") + if not isinstance(pin["main_sha"], str): + raise ValueError("invalid next_policy main_sha") + report.require_sha(pin["main_sha"]) + if set(value) != keys: + raise ValueError("budget file contains missing or unsupported fields") + limit = value["max_custom_patch_lines"] + if type(limit) is not int or limit < 0: + raise ValueError("max_custom_patch_lines must be a nonnegative integer") + if version == 2 and (value["generated_files"] != "strict" or limit != 0): + raise ValueError("schema v2 requires generated_files=strict and a zero budget") + return value + + +def next_policy_sha(repo: Path, repository: str, base: str, authenticated_main: str) -> str: + """Select executable policy only from an authenticated target base, never its candidate.""" + base = report.require_sha(base) + authenticated_main = report.require_sha(authenticated_main) + if not report.REPOSITORY.fullmatch(repository): + raise ValueError("invalid policy repository") + expected_origin = f"https://github.com/{repository}.git" + if report.git(repo, "remote", "get-url", "origin").decode().strip() != expected_origin: + raise ValueError("policy repository origin mismatch") + fetched_main = report.git(repo, "rev-parse", "refs/remotes/origin/main").decode().strip() + if fetched_main != authenticated_main: + raise ValueError("authenticated main changed during policy selection; retry") + pin = read_policy(repo, base).get("next_policy") + # read_policy validates the shape of every present next_policy pin. + if pin is None: + raise ValueError("trusted next base has no reviewed policy pin") + if pin["repository"] != repository: + raise ValueError("next policy pin belongs to another repository") + policy = report.require_sha(pin["main_sha"]) + # A reachable side-branch commit is not an adopted main policy revision. + adopted = report.git(repo, "rev-list", "--first-parent", authenticated_main).splitlines() + if policy.encode() not in adopted: + raise ValueError("next policy pin is not in authenticated main first-parent history") + read_policy(repo, policy) + # Reject pre-protocol checkers rather than executing code that cannot bind a next pin. + source = report.git(repo, "show", f"{policy}:scripts/castiron/custom_code_budget.py") + tree = ast.parse(source) + capable = any( + isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "NEXT_POLICY_VERSION" + for target in node.targets + ) + and isinstance(node.value, ast.Constant) + and type(node.value.value) is int + and node.value.value == NEXT_POLICY_VERSION + for node in tree.body + ) + if not capable: + raise ValueError("pinned checker does not support reviewed next policies") + return cast(str, policy) + + +class HumanReviewRules(TypedDict): + version: int + associations: list[str] + negative_pattern: str + + +HUMAN_REVIEW_RULES: HumanReviewRules = { + "version": 1, + "associations": ["OWNER", "MEMBER", "COLLABORATOR"], + "negative_pattern": ( + r"automated[\s_:-]*(?:source[\s_:-]*)?review|source[- ]only review|" + r"(?:not|isn't|does not constitute|does not substitute for)\s+(?:an?\s+)?human\s+approv|" + r"non[- ]human[- ]approval" + ), +} + + +def has_human_approval(reviews: list[dict[str, Any]], author: str, head: str) -> bool: + """Versioned review predicate; publisher consumes these same trusted rules.""" + latest: dict[str, dict[str, Any]] = {} + for review in reviews: + if review["state"] != "PENDING": + login = review["user"]["login"] + if login not in latest or review["id"] > latest[login]["id"]: + latest[login] = review + return any( + review["state"] == "APPROVED" + and review["commit_id"] == head + and review["user"]["type"] == "User" + and review.get("author_association") in HUMAN_REVIEW_RULES["associations"] + and not review["user"]["login"].endswith("[bot]") + and review["user"]["login"] != author + and not re.search( + HUMAN_REVIEW_RULES["negative_pattern"], review.get("body") or "", re.IGNORECASE + ) + for review in latest.values() + ) + + +def require_human_policy_review(repository: str, head: str) -> int: + """Only live, exact-head human reviews authorize a next policy activation/update.""" + root = f"repos/{repository}" + pulls = report.api("GET", f"{root}/commits/{head}/pulls?per_page=100") + current: list[dict[str, Any]] = [] + for item in pulls: + pull = report.api("GET", f"{root}/pulls/{item['number']}") + if ( + pull["state"] == "open" + and pull["head"]["sha"] == head + and pull["base"]["ref"] == "next" + and pull["base"]["repo"]["full_name"] == repository + ): + current.append(pull) + if len(current) != 1: + raise ValueError("policy change must identify one open next PR") + pull = current[0] + all_reviews: list[dict[str, Any]] = [] + page = 1 + while True: + reviews = report.api( + "GET", f"{root}/pulls/{pull['number']}/reviews?per_page=100&page={page}" + ) + all_reviews.extend(reviews) + if len(reviews) < 100: + break + page += 1 + if not has_human_approval(all_reviews, pull["user"]["login"], head): + raise ValueError("next policy changes require an exact-head human approving review") + return int(pull["number"]) + + +def select_policy( + repo: Path, + repository: str, + branch: str, + base: str, + head: str, + authenticated_main: str, + *, + pull_heads: list[str] | None = None, +) -> dict[str, Any]: + """The trusted selector runs before checking out any pinned executable code.""" + base, head, authenticated_main = map(report.require_sha, (base, head, authenticated_main)) + if branch == "main": + return { + "schema_version": SELECTION_RECEIPT_VERSION, + "target_branch": branch, + "base_sha": base, + "head_sha": head, + "policy_sha": authenticated_main, + "bootstrap": False, + } + if branch != "next": + raise ValueError("unsupported policy target") + # Malformed policies are errors, never a reason to enter bootstrap mode. + base_policy = read_policy(repo, base) + head_policy = read_policy(repo, head) + bootstrap = "next_policy" not in base_policy + if bootstrap: + if "next_policy" not in head_policy or ( + pull_heads is None and changed_paths(repo, base, head) != {POLICY.encode()} + ): + raise ValueError("missing next pin allows only reviewed policy-only initialization") + policy = authenticated_main + else: + policy = next_policy_sha(repo, repository, base, authenticated_main) + # No candidate-selected version executes, including in a policy-only PR. + head_base_policy = read_policy(repo, merge_base(repo, base, head)) + if head_policy.get("next_policy") != head_base_policy.get("next_policy"): + if "next_policy" not in head_policy or ( + pull_heads is None and changed_paths(repo, base, head) != {POLICY.encode()} + ): + raise ValueError("next pin changes must be policy-only and cannot remove the pin") + next_policy_sha(repo, repository, head, authenticated_main) + policy_reviews: list[dict[str, Any]] = [] + candidates = pull_heads if pull_heads is not None else [head] + if not candidates: + raise ValueError("next evaluation has no verified constituent PR") + for candidate in candidates: + candidate_policy = read_policy(repo, candidate) + candidate_base_policy = read_policy(repo, merge_base(repo, base, candidate)) + pin_changed = candidate_policy.get("next_policy") != candidate_base_policy.get( + "next_policy" + ) + budget_increased = ( + candidate_policy["max_custom_patch_lines"] + > candidate_base_policy["max_custom_patch_lines"] + ) + if pin_changed or budget_increased: + if changed_paths(repo, base, candidate) != {POLICY.encode()}: + raise ValueError("each next policy activation or increase must be policy-only") + number = require_human_policy_review(repository, candidate) + policy_reviews.append({"number": number, "head_sha": candidate}) + return { + "schema_version": SELECTION_RECEIPT_VERSION, + "target_branch": branch, + "base_sha": base, + "head_sha": head, + "policy_sha": policy, + "bootstrap": bootstrap, + "policy_reviews": policy_reviews, + "human_review_rules": HUMAN_REVIEW_RULES, + } + + +def select_github_policy( + repo: Path, + repository: str, + event: dict[str, Any], + trusted_sha: str, + *, + source_workflow: str = ".github/workflows/castiron-custom-code.yml", +) -> dict[str, Any]: + """Authenticate event, refs and graph in a fresh store before choosing checker code.""" + if source_workflow not in ( + ".github/workflows/castiron-custom-code.yml", + ".github/workflows/ci.yml", + ): + raise ValueError("unsupported policy source workflow") + if ( + not report.REPOSITORY.fullmatch(repository) + or event["repository"]["full_name"] != repository + ): + raise ValueError("policy event repository mismatch") + root = f"repos/{repository}" + metadata = report.api("GET", root) + if metadata["default_branch"] != "main": + raise ValueError("policy selector requires the main default branch") + signal = event["workflow_run"] + run = report.api("GET", f"{root}/actions/runs/{signal['id']}") + if ( + run["repository"]["full_name"] != repository + or run["head_sha"] != signal["head_sha"] + or run["status"] != "completed" + or run["event"] != signal["event"] + or run["run_attempt"] != signal["run_attempt"] + or run["path"].split("@", 1)[0] != source_workflow + ): + raise ValueError("unexpected or superseded policy source run") + workflow = report.api("GET", f"{root}/actions/workflows/{source_workflow.rsplit('/', 1)[1]}") + if ( + type(run.get("workflow_id")) is not int + or run["workflow_id"] != workflow["id"] + or workflow["path"] != source_workflow + ): + raise ValueError("policy source run is not the canonical workflow") + head = report.require_sha(run["head_sha"]) + if run["event"] == "pull_request": + current = report.current_pull(repository, run) + if current is None: + raise ValueError("policy run must identify one current target PR") + _, branch = current + elif run["event"] == "merge_group": + branch = next( + ( + name + for name in report.TARGET_BRANCHES + if run["head_branch"].startswith(f"gh-readonly-queue/{name}/") + ), + "", + ) + if not branch: + raise ValueError("policy queue target is unsupported") + else: + raise ValueError("unsupported policy source event") + trusted_sha = report.require_sha(trusted_sha) + if branch == "main": + if source_workflow != ".github/workflows/castiron-custom-code.yml": + raise ValueError("CI policy adapter is restricted to next") + return select_policy(repo, repository, branch, trusted_sha, head, trusted_sha) + main = report.require_sha(report.api("GET", f"{root}/git/ref/heads/main")["object"]["sha"]) + base = report.require_sha(report.api("GET", f"{root}/git/ref/heads/next")["object"]["sha"]) + if repo.exists(): + raise ValueError("policy object directory must be fresh") + repo.mkdir() + report.git(repo, "init", "--quiet", "--bare") + report.git(repo, "remote", "add", "origin", f"https://github.com/{repository}.git") + report.git( + repo, + "fetch", + "--quiet", + "--no-tags", + "origin", + "+refs/heads/main:refs/remotes/origin/main", + "+refs/heads/next:refs/remotes/origin/next", + head, + ) + if report.git(repo, "rev-parse", "refs/remotes/origin/next").decode().strip() != base: + raise ValueError("next base changed during selection; retry") + heads: list[str] | None = None + if run["event"] == "merge_group": + if report.git(repo, "merge-base", base, head).decode().strip() != base: + raise ValueError("policy queue does not contain current next base") + entries = queued_entries(repository, branch) + if not any(queue == head for _, queue in entries): + raise ValueError("policy queue entry is no longer live") + heads = [] + for candidate, queue in entries: + report.git(repo, "fetch", "--quiet", "--no-tags", "origin", queue) + if report.git(repo, "merge-base", queue, head).decode().strip() == queue: + report.git(repo, "fetch", "--quiet", "--no-tags", "origin", candidate) + heads.append(candidate) + if not heads: + raise ValueError("policy queue has no verified constituent PRs") + return select_policy(repo, repository, branch, base, head, main, pull_heads=heads) + + +def merge_base(repo: Path, base: str, head: str) -> str: + """Distinguish actual PR policy edits from later changes on the target.""" + return cast( + str, + report.git(repo, "merge-base", report.require_sha(base), report.require_sha(head)) + .decode() + .strip(), + ) + + +def changed_paths(repo: Path, base: str, head: str) -> set[bytes]: + """Use the entire PR diff, not its last commit or a truncated API file list.""" + start = merge_base(repo, base, head) + return set( + report.git( + repo, + "diff", + "--name-only", + "-z", + "--no-renames", + "--no-ext-diff", + "--no-textconv", + start, + head, + "--", + ).split(b"\0") + ) - {b""} + + +def queued_entries(repository: str, branch: str) -> list[tuple[str, str]]: + """Query the actual queue; never infer PR membership from commit messages.""" + owner, name = repository.split("/") + query = """query($owner:String!,$name:String!,$branch:String!,$cursor:String) { + repository(owner:$owner,name:$name) { mergeQueue(branch:$branch) { + entries(first:100,after:$cursor) { + nodes { pullRequest { headRefOid } headCommit { oid } } + pageInfo { hasNextPage endCursor } + } + } } + }""" + cursor = None + heads: list[tuple[str, str]] = [] + while True: + response = report.api( + "POST", + "graphql", + { + "query": query, + "variables": {"owner": owner, "name": name, "branch": branch, "cursor": cursor}, + }, + ) + entries = response["data"]["repository"]["mergeQueue"]["entries"] + for node in entries["nodes"]: + # Entries still awaiting preparation have no synthetic queue commit. + if node["headCommit"] is not None: + heads.append( + ( + report.require_sha(node["pullRequest"]["headRefOid"]), + report.require_sha(node["headCommit"]["oid"]), + ) + ) + page = entries["pageInfo"] + if not page["hasNextPage"]: + return heads + if not page["endCursor"] or page["endCursor"] == cursor: + raise ValueError("invalid merge-queue pagination") + cursor = page["endCursor"] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + selection = commands.add_parser("select-policy") + selection.add_argument("--repo", type=Path, required=True) + selection.add_argument("--repository", required=True) + selection.add_argument("--event-path", type=Path, required=True) + selection.add_argument("--trusted-sha", required=True) + selection.add_argument("--out", type=Path, required=True) + selection.add_argument( + "--source-workflow", + choices=(".github/workflows/castiron-custom-code.yml", ".github/workflows/ci.yml"), + default=".github/workflows/castiron-custom-code.yml", + ) + args = parser.parse_args(argv) + selected = select_github_policy( + args.repo, + args.repository, + json.loads(args.event_path.read_text()), + args.trusted_sha, + source_workflow=args.source_workflow, + ) + args.out.write_text(json.dumps(selected, sort_keys=True) + "\n") + if "GITHUB_OUTPUT" in os.environ: + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.writelines( + f"{key}={json.dumps(value) if isinstance(value, (bool, list, dict)) else value}\n" + for key, value in selected.items() + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/castiron/custom_code_test_support.py b/scripts/castiron/custom_code_test_support.py index 7c9404b022..bd7f1885d3 100644 --- a/scripts/castiron/custom_code_test_support.py +++ b/scripts/castiron/custom_code_test_support.py @@ -61,6 +61,7 @@ def baseline(self) -> tuple[str, str]: def source_run(head: str, event: str = "pull_request") -> dict[str, Any]: return { "id": 123, + "workflow_id": 456, "event": event, "head_sha": head, "head_branch": "gh-readonly-queue/main/pr-7-example" if event == "merge_group" else "sdk", diff --git a/scripts/castiron/fixtures/github_publisher.cjs b/scripts/castiron/fixtures/github_publisher.cjs index 912dd2efd2..0c4d7484bc 100644 --- a/scripts/castiron/fixtures/github_publisher.cjs +++ b/scripts/castiron/fixtures/github_publisher.cjs @@ -5,12 +5,16 @@ const data = JSON.parse(fs.readFileSync(0, 'utf8')); const writes = []; const github = { rest: { - actions: { getWorkflowRun: async () => ({ data: data.run }) }, + actions: { + getWorkflowRun: async () => ({ data: data.run }), + getWorkflow: async () => ({ data: { id: 456, path: '.github/workflows/castiron-custom-code.yml' } }), + }, pulls: { get: async ({ pull_number }) => ({ data: data.current_sequence?.shift() || data.pulls_by_number?.[pull_number] || data.current, }), list: 'pulls', + listReviews: 'reviews', }, git: { getRef: async ({ ref }) => ({ @@ -46,6 +50,7 @@ const github = { }; }, paginate: async (method, params) => { + if (method === 'reviews') return data.reviews_by_number?.[params.pull_number] || data.reviews || []; if (method === 'statuses') return data.previous_statuses || []; if (method === 'comments') return data.comments || []; if (method === 'associations') return []; diff --git a/scripts/castiron/generated-files.json b/scripts/castiron/generated-files.json index cba37ab70f..2e09fa5e40 100644 --- a/scripts/castiron/generated-files.json +++ b/scripts/castiron/generated-files.json @@ -3,11 +3,11 @@ "files": { ".github/workflows/castiron-custom-code-comment.yml": { "mode": "100644", - "sha256": "02b1c497461fcaf604e1389e7dcab06cfcca01635a8cb2e92f8769245a2aaead" + "sha256": "fe9dc84d5b5da5f9d282ef7d1fde4cb93fbd84b5100f5d64bf9b873ac6772f95" }, ".github/workflows/castiron-custom-code.yml": { "mode": "100644", - "sha256": "ccd67760a8cfcc17ebdf5c8df7e330ee7be5199cb62232afdf9035fea68bbc2f" + "sha256": "402da2fd16cc575eef7f4bf6bb1cff25272ee350452cb776f17c1589c036a3a1" }, "api.md": { "mode": "100644", @@ -15,7 +15,7 @@ }, "scripts/castiron/CUSTOM_CODE.md": { "mode": "100644", - "sha256": "c83e57879ee995c2871ff41a775758301d634199a00886e339a41212db711f39" + "sha256": "6578db0d17cf297ea01b862f772a716bef56a81192498f423463505950707701" }, "scripts/castiron/README.md": { "mode": "100644", @@ -23,19 +23,23 @@ }, "scripts/castiron/custom_code_budget.py": { "mode": "100755", - "sha256": "e6f19706c26f9b996f7d6d5f8a654e54fbc8c49a5d2fce045c1996b119feee33" + "sha256": "3f576ca4ddf34fdb3c43093192ca382e38ac66a703c1ff000f87fa288c3faa6c" + }, + "scripts/castiron/custom_code_policy.py": { + "mode": "100755", + "sha256": "e9b39f4f881a3adfe1cb6a01bb2bb0522463c98c554b3c21ae8d3b2f13ce23f5" }, "scripts/castiron/custom_code_report.py": { "mode": "100755", - "sha256": "157ed30ac9d54d20fe5f7e625dc8ee5958beb91cc7e30aaa97cfba8fb3e06e21" + "sha256": "6b7cfda1f85426daaa2e4f6186e2d1b20ac638dadaa54be8142ecec89fefd880" }, "scripts/castiron/custom_code_test_support.py": { "mode": "100644", - "sha256": "27a1c5ee377fa601f896191e3914cb74e4bf65ab814819a408e884332b713bde" + "sha256": "15b2c1243f1cbec53b73707e5806a8566d40f78763fe85c4ab7dc48bc1405a6c" }, "scripts/castiron/fixtures/github_publisher.cjs": { "mode": "100644", - "sha256": "fc80b1aff054f8a2ea16fc6bd9cff5ed4e4a48eebc7f3af8427947796fb8d865" + "sha256": "40e08910601ccac410f22ed9397129b2cc1ff985e96109cda8d05608e58eaa56" }, "scripts/castiron/ruff.toml": { "mode": "100644", @@ -43,19 +47,23 @@ }, "scripts/castiron/test_custom_code_budget.py": { "mode": "100644", - "sha256": "cf4938bca739d01f1db2a421ec97e4fe8b41b8bc2d2c634a01247e36cb464649" + "sha256": "71527f2e594989bb3f7f94a723322714bf9bd2669aa861719f29f462b925a4b6" }, "scripts/castiron/test_custom_code_github.py": { "mode": "100644", - "sha256": "aa170458b1b58523df78073a96ba8c58b75fce03206ff7d62d99b29791c6ca39" + "sha256": "f0f3fa6810ef73b0656d722e65125003a5685b61324dda6c93d1d0dbf619df37" + }, + "scripts/castiron/test_custom_code_policy.py": { + "mode": "100644", + "sha256": "01b102c8e1f854ec2c4d2480dbcd55ae38008812fb3196e0309eb6ac8fbbb6d4" }, "scripts/castiron/test_custom_code_publication.py": { "mode": "100644", - "sha256": "99b266761f427d77990e68ce2fdea4b5f1a5b175612b3a9f4355c094939130ca" + "sha256": "23d28a75f62d65c0313081194726d34c85241234de9dbd7f085d2c2b1aa0406d" }, "scripts/castiron/test_custom_code_report.py": { "mode": "100644", - "sha256": "5c17ac9b5596e7f69aaefe3f29615cdac425323594854a91e1759c88d92a68bb" + "sha256": "f8f6b883c545963b4f82d8b078734298294607a9371e70e01fb0c3a37304be61" }, "src/openai/__init__.py": { "mode": "100644", @@ -63,7 +71,7 @@ }, "src/openai/_client.py": { "mode": "100644", - "sha256": "1b40186722288a39a6cdf5a50e2065feafec996883de9f7cd44edd0af88778ca" + "sha256": "4169b69ca58d5d53bfeb0f9aa8c230bd6893973d6710d6b9badc65e089e4b055" }, "src/openai/_data_residency.py": { "mode": "100644", @@ -299,7 +307,7 @@ }, "src/openai/resources/beta/agents/environments/files.py": { "mode": "100644", - "sha256": "d0b6ae5498f874f99c80ab34838b2cb8d3ff7643366ac193389a254bfdc0ecb0" + "sha256": "5d59d1b50d55a4747fff39c8a93e1a30733a6b9a1da0f0a2728151afbeeab850" }, "src/openai/resources/beta/agents/environments/templates.py": { "mode": "100644", @@ -311,7 +319,7 @@ }, "src/openai/resources/beta/agents/sessions/artifacts.py": { "mode": "100644", - "sha256": "f0525179b80b77b74082bffdf5db43ba690d1065c4bdb7e08e26bb18b6524174" + "sha256": "c3bea665d60be82024758eb3ddee8c317ca9809b3e06004a8b51d04e223206f6" }, "src/openai/resources/beta/agents/sessions/events.py": { "mode": "100644", @@ -383,7 +391,7 @@ }, "src/openai/resources/beta/beta.py": { "mode": "100644", - "sha256": "17075a4e7e706cfb252f04cdf49cdbcd8c36d531c5c9d4c8350bb09054105905" + "sha256": "991cf89f05df88821b18be6687fb0aff06b09c595af89d1b338858454ed4ca37" }, "src/openai/resources/beta/chatkit/__init__.py": { "mode": "100644", @@ -415,7 +423,7 @@ }, "src/openai/resources/beta/responses/responses.py": { "mode": "100644", - "sha256": "e631b84ca7b00615a9e2f232d45747ec39f284ffa1ceb71517032e969bb386bb" + "sha256": "e02479e8641d6e5cbf70086381400eb9f96195fe3fbc08e2ba0466e08315079f" }, "src/openai/resources/beta/threads/__init__.py": { "mode": "100644", @@ -595,11 +603,11 @@ }, "src/openai/resources/live/forks.py": { "mode": "100644", - "sha256": "440a44366c6896286b95c398b2ea98f34844600af22513e9ebbe8ca497556d90" + "sha256": "9fb98f22e70bc172c9027b765d29040ee399906a2bedf6b93e7e9a0467df5fcd" }, "src/openai/resources/live/live.py": { "mode": "100644", - "sha256": "f1ae78c0bffef766fbb859f1a9ffb307bcbab646b9da2d741099449914f9a42a" + "sha256": "6f092c04cd1244a28befac92f25a3e4c5bc204d2fe66d52c771704ee98e28b2c" }, "src/openai/resources/live/sessions.py": { "mode": "100644", @@ -607,7 +615,7 @@ }, "src/openai/resources/live/sideband.py": { "mode": "100644", - "sha256": "486f51b77fea40ab0f6393d0aa01b083aefff9fa2ba8e9c076e4757a10d7ab3a" + "sha256": "e3a8635781153cd577ff3b6e95bc949304d9c888e7de60f8aac25b6ef4790022" }, "src/openai/resources/models.py": { "mode": "100644", @@ -635,7 +643,7 @@ }, "src/openai/resources/realtime/realtime.py": { "mode": "100644", - "sha256": "a1af3c8791a4f29b66e3401348b6c6dea6dc658a8a6bb88843cdb30b4626119c" + "sha256": "a4b57db70118e3d9c468c8f7528a6b4db979fc3b0d5665ef47d7622998f509ca" }, "src/openai/resources/realtime/translations/__init__.py": { "mode": "100644", @@ -647,7 +655,7 @@ }, "src/openai/resources/realtime/translations/translations.py": { "mode": "100644", - "sha256": "c34a5218f753a120602bb7cca62073ee032091155e22886c9f633dfcf1263cac" + "sha256": "6d8eb9eb1962bd24af7eab0273ed01be77d31012a34c4b99f0f6f381253cb475" }, "src/openai/resources/responses/__init__.py": { "mode": "100644", @@ -667,7 +675,7 @@ }, "src/openai/resources/responses/responses.py": { "mode": "100644", - "sha256": "2239f2e9fe19aa019d2580a0f74a5d2efcedc2c284650ee1c2567af334a045e6" + "sha256": "730d34b7ec479169c91f15d021b3c3f03de91c52fb4984cc4058b0bc60b9be2b" }, "src/openai/resources/safety/__init__.py": { "mode": "100644", @@ -719,7 +727,7 @@ }, "src/openai/resources/uploads/uploads.py": { "mode": "100644", - "sha256": "abfb981b502ba8507619b48debb087db5aa3481767c6f25ef14190062bb5dda2" + "sha256": "cd5d957c36eb1e5da2bf0174d86817368e990e2657c5960a9cd5b8a9e94d0802" }, "src/openai/resources/vector_stores/__init__.py": { "mode": "100644", @@ -759,7 +767,7 @@ }, "src/openai/types/__init__.py": { "mode": "100644", - "sha256": "5caaa29a79510a0a3e4a92f7b59d3ccb3f8f6651706f658738cb5e2a22b91481" + "sha256": "47c98e4ffd0eb3a28a9d60b78facf0bdd165d6d35b24ebb6bea5834a0165eeea" }, "src/openai/types/admin/__init__.py": { "mode": "100644", @@ -7295,7 +7303,7 @@ }, "tests/api_resources/admin/organization/projects/test_api_keys.py": { "mode": "100644", - "sha256": "1fa9769ccdc65d47c8c17c1e525a69e70ebb6d9248a017eb5fe4de7a337ec8ee" + "sha256": "49c21791da11c49c01c26b40da8e826b37a496d8e209c4b76911be04435d5048" }, "tests/api_resources/admin/organization/projects/test_certificates.py": { "mode": "100644", @@ -7835,7 +7843,7 @@ }, "tests/api_resources/test_videos.py": { "mode": "100644", - "sha256": "71e362116687a3225b36f396a14090f27786cba7b40bb136d21a1d5553532db1" + "sha256": "3be3adea04934a991e9906d794468a0e33116dd1a2ca3762b360870a626f82fc" }, "tests/api_resources/test_webhooks.py": { "mode": "100644", diff --git a/scripts/castiron/test_custom_code_github.py b/scripts/castiron/test_custom_code_github.py index a231a5a6da..1d970a0ffd 100644 --- a/scripts/castiron/test_custom_code_github.py +++ b/scripts/castiron/test_custom_code_github.py @@ -139,6 +139,7 @@ def local_git(repo: Path, *args: str, input_bytes: bytes | None = None) -> bytes ("next-bad-hash", broken, None), ): with self.subTest(label=label): + result: dict[str, Any] | None = None api = GitHubAPI( "d" * 40, revision ) # PR metadata already differs from captured main. @@ -166,14 +167,33 @@ def local_git(repo: Path, *args: str, input_bytes: bytes | None = None) -> bytes ) # Advance main before both budget evaluation and publication. api.main = api.next = api.pull["base"]["sha"] = "e" * 40 - result, _ = budget.github_evaluate( - objects, - REPOSITORY, - {"repository": {"full_name": REPOSITORY}, "workflow_run": api.run}, - base, - out, - ) - self.assertEqual((result["base_sha"], result["head_sha"]), (base, revision)) + if label.startswith("next"): + # Advisory reporting alone cannot authorize next without + # the trusted selector's protected-base pin receipt. + with self.assertRaisesRegex(ValueError, "selection"): + budget.github_evaluate( + objects, + REPOSITORY, + {"repository": {"full_name": REPOSITORY}, "workflow_run": api.run}, + base, + out, + ) + else: + result, _ = budget.github_evaluate( + objects, + REPOSITORY, + {"repository": {"full_name": REPOSITORY}, "workflow_run": api.run}, + base, + out, + selection={ + "schema_version": 1, + "target_branch": "main", + "base_sha": base, + "head_sha": revision, + "policy_sha": base, + }, + ) + self.assertEqual((result["base_sha"], result["head_sha"]), (base, revision)) report.publish_comment( measured, REPOSITORY, 3, 123, 1, artifact_run_id=9, artifact_run_attempt=3 ) @@ -182,6 +202,13 @@ def local_git(repo: Path, *args: str, input_bytes: bytes | None = None) -> bytes self.assertIn("castiron:run:v1:123:1", body) self.assertIn(base, body) self.assertIn("/actions/runs/9", body) + if label.startswith("next"): + self.assertIn( + "Report unavailable" if total is None else "Generated baselines verified", + body, + ) + continue + assert result is not None if total is None: self.assertEqual(result["checks"]["budget"]["state"], "failure") self.assertIn("could not verify", result["checks"]["budget"]["description"]) @@ -327,7 +354,19 @@ def local_fetch(repo: Path, *args: str, input_bytes: bytes | None = None) -> byt ), ): repo = Path(temp) / "queue.git" - result, _ = budget.github_evaluate(repo, "openai/example", event, base) + result, _ = budget.github_evaluate( + repo, + "openai/example", + event, + base, + selection={ + "schema_version": 1, + "target_branch": "main", + "base_sha": base, + "head_sha": queue_head, + "policy_sha": base, + }, + ) self.assertEqual(result["head_sha"], queue_head) self.assertEqual(result["checks"]["budget"]["state"], "success", result["checks"]) self.assertEqual(result["total"], 1) @@ -335,11 +374,92 @@ def local_fetch(repo: Path, *args: str, input_bytes: bytes | None = None) -> byt [c["state"] for c in result["checks"].values()], ["success", "success"] ) - def test_next_queue_uses_separate_main_policy_without_main_ancestry(self) -> None: + def test_next_queue_bootstrap_isolated_per_verified_constituent(self) -> None: + generated, _ = self.baseline() + self.git("branch", "codegen/test", generated) + policy = {"schema_version": 1, "max_custom_patch_lines": 10} + self.write(budget.POLICY, json.dumps(policy)) + base = self.commit("unpinned next budget") + self.git("checkout", "-q", "-b", "initialize", base) + self.write( + budget.POLICY, + json.dumps({**policy, "next_policy": {"repository": REPOSITORY, "main_sha": base}}), + ) + pin_head = self.commit("policy-only initialization") + self.git("checkout", "-q", "-b", "sdk", base) + self.write("generated.py", "generated\ncustom\n") + sdk_head = self.commit("unrelated SDK PR") + self.git("checkout", "-q", "-b", "queue", base) + self.git("merge", "--squash", "initialize") + pin_queue = self.commit("synthetic policy queue commit") + self.git("merge", "--squash", "sdk") + queue_head = self.commit("synthetic combined queue commit") + run = source_run(queue_head, "merge_group") + run["head_branch"] = "gh-readonly-queue/next/pr-7-example" + event = {"repository": {"full_name": REPOSITORY}, "workflow_run": run} + original_git = report.git + + def local_fetch(repo: Path, *args: str, input_bytes: bytes | None = None) -> bytes: + if args[0] == "fetch": + args = tuple(str(self.repo) if arg == "origin" else arg for arg in args) + return original_git(repo, *args, input_bytes=input_bytes) + + for entries, state in ( + ([(pin_head, pin_queue), (sdk_head, queue_head)], "success"), + ([(queue_head, queue_head)], "failure"), + ): + with ( + self.subTest(entries=entries), + tempfile.TemporaryDirectory() as temp, + mock.patch.object( + budget.report, + "api", + side_effect=[ + {"default_branch": "main", "private": True}, + run, + {"object": {"sha": base}}, + {"object": {"sha": base}}, + [{"type": "merge_queue"}], + ], + ), + mock.patch.object(budget, "queued_entries", return_value=entries), + mock.patch.object(budget.report, "git", side_effect=local_fetch), + ): + result, _ = budget.github_evaluate( + Path(temp) / "queue.git", + REPOSITORY, + event, + base, + selection={ + "schema_version": 1, + "target_branch": "next", + "base_sha": base, + "head_sha": queue_head, + "policy_sha": base, + "bootstrap": True, + }, + ) + self.assertEqual(result["checks"]["isolation"]["state"], state) + self.assertEqual(result["checks"]["budget"]["state"], "success") + self.assertEqual(result["limit"], 10) + self.assertEqual(result["total"], 1) + + def test_next_queue_uses_reviewed_main_policy_after_main_advances(self) -> None: generated, _ = self.baseline() self.git("branch", "codegen/test", generated) self.write(budget.POLICY, json.dumps({"schema_version": 1, "max_custom_patch_lines": 10})) - base = self.commit("budget") + approved_policy = self.commit("budget") + self.write( + budget.POLICY, + json.dumps( + { + "schema_version": 1, + "max_custom_patch_lines": 10, + "next_policy": {"repository": "openai/example", "main_sha": approved_policy}, + } + ), + ) + base = self.commit("reviewed next policy") self.git("checkout", "-q", "-b", "sdk", base) self.write("generated.py", "generated\ncustom\n") pr_head = self.commit() @@ -385,16 +505,119 @@ def local_fetch(repo: Path, *args: str, input_bytes: bytes | None = None) -> byt ), ): repo = Path(temp) / "queue.git" - result, _ = budget.github_evaluate(repo, "openai/example", event, policy) + result, _ = budget.github_evaluate( + repo, + "openai/example", + event, + approved_policy, + selection={ + "target_branch": "next", + "base_sha": base, + "head_sha": queue_head, + "policy_sha": approved_policy, + "bootstrap": False, + "schema_version": 1, + }, + ) + self.assertEqual(result["head_sha"], queue_head) + self.assertEqual(result["checks"]["budget"]["state"], "success", result["checks"]) + self.assertEqual(result["limit"], 10) + self.assertEqual(result["base_sha"], base) + self.assertEqual(result["policy_sha"], approved_policy) + self.assertEqual(result["target_branch"], "next") + self.assertEqual(result["total"], 1) + self.assertEqual( + [c["state"] for c in result["checks"].values()], ["success", "success"] + ) + + def test_next_queue_bootstrap_with_separate_sdk_pr(self) -> None: + generated, _ = self.baseline() + self.git("branch", "codegen/test", generated) + self.write(budget.POLICY, json.dumps({"schema_version": 1, "max_custom_patch_lines": 10})) + approved_policy = self.commit("budget") + self.write( + budget.POLICY, + json.dumps( + { + "schema_version": 1, + "max_custom_patch_lines": 10, + "next_policy": {"repository": "openai/example", "main_sha": approved_policy}, + } + ), + ) + pin_head = self.commit("isolated initialization PR") + base = approved_policy + self.git("checkout", "-q", "-b", "sdk", base) + self.write("generated.py", "generated\ncustom\n") + pr_head = self.commit() + self.git("checkout", "-q", "-b", "queue", base) + self.git("merge", "--squash", "sdk") + self.commit("SDK queue entry") + self.git("merge", "--squash", pin_head) + queue_head = self.commit("synthetic queue commit") + self.assertNotEqual(self.git("merge-base", pr_head, queue_head), pr_head) + policy = approved_policy + run = source_run(queue_head, "merge_group") + run["head_branch"] = "gh-readonly-queue/next/pr-7-example" + event = { + "repository": {"full_name": "openai/example"}, + "workflow_run": run, + } + original_git = report.git + + def local_fetch(repo: Path, *args: str, input_bytes: bytes | None = None) -> bytes: + if args[0] == "fetch": + args = tuple(str(self.repo) if arg == "origin" else arg for arg in args) + return original_git(repo, *args, input_bytes=input_bytes) + + with ( + tempfile.TemporaryDirectory() as temp, + mock.patch.object( + budget.report, + "api", + side_effect=[ + {"default_branch": "main", "private": True}, + run, + {"object": {"sha": policy}}, + {"object": {"sha": base}}, + [{"type": "merge_queue"}], + ], + ), + mock.patch.object( + budget, + "queued_entries", + return_value=[(pin_head, queue_head), (pr_head, queue_head)], + ), + mock.patch.object( + budget.report, + "git", + side_effect=local_fetch, + ), + ): + repo = Path(temp) / "queue.git" + result, _ = budget.github_evaluate( + repo, + "openai/example", + event, + approved_policy, + selection={ + "target_branch": "next", + "base_sha": base, + "head_sha": queue_head, + "policy_sha": approved_policy, + "bootstrap": True, + "schema_version": 1, + }, + ) self.assertEqual(result["head_sha"], queue_head) - self.assertEqual(result["checks"]["budget"]["state"], "failure", result["checks"]) - self.assertEqual(result["limit"], 0) + self.assertEqual(result["checks"]["budget"]["state"], "success", result["checks"]) + self.assertEqual(result["limit"], 10) self.assertEqual(result["base_sha"], base) - self.assertEqual(result["policy_sha"], policy) + self.assertEqual(result["policy_sha"], approved_policy) self.assertEqual(result["target_branch"], "next") self.assertEqual(result["total"], 1) self.assertEqual( - [c["state"] for c in result["checks"].values()], ["success", "failure"] + [c["state"] for c in result["checks"].values()], ["success", "success"] ) @@ -415,7 +638,46 @@ def evaluate(self, *, trusted_report: Path | None = None) -> tuple[dict[str, Any "repository": {"full_name": REPOSITORY}, "workflow_run": source_run(self.head, self.api.run["event"]), } - return budget.github_evaluate(self.repo, REPOSITORY, event, self.base, trusted_report) + return budget.github_evaluate( + self.repo, + REPOSITORY, + event, + self.base, + trusted_report, + selection={ + "schema_version": 1, + "target_branch": "main", + "base_sha": self.base, + "head_sha": self.head, + "policy_sha": self.base, + }, + ) + + def test_main_rejects_missing_or_retargeted_selection(self) -> None: + for branch in (None, "next"): + selection = ( + None + if branch is None + else { + "schema_version": 1, + "target_branch": branch, + "base_sha": self.base, + "head_sha": self.head, + "policy_sha": self.base, + } + ) + with ( + self.subTest(branch=branch), + self.assertRaisesRegex(ValueError, "policy selection"), + ): + budget.github_evaluate( + self.repo, + REPOSITORY, + {"repository": {"full_name": REPOSITORY}, "workflow_run": self.api.run}, + self.base, + selection=selection, + ) + self.assertFalse(self.repo.exists()) def test_merge_queue_rule_is_required_before_passing(self) -> None: self.api.run = source_run(self.head, "merge_group") @@ -472,7 +734,12 @@ def test_reuses_only_matching_main_job_report(self) -> None: mock.patch.object(budget, "evaluate", return_value=({}, b"")) as evaluate, ): if stale: - with self.assertRaisesRegex(ValueError, "trusted report does not match"): + with self.assertRaisesRegex( + ValueError, + "trusted policy selection" + if stale == "base" + else "trusted report does not match", + ): self.evaluate(trusted_report=self.root) evaluate.assert_not_called() else: @@ -482,7 +749,7 @@ def test_reuses_only_matching_main_job_report(self) -> None: evaluate.call_args.kwargs["measurement"], (measured, b"verified patch") ) - def test_next_reuses_target_snapshot_but_keeps_main_policy(self) -> None: + def test_next_reuses_target_snapshot_but_uses_selected_main_policy(self) -> None: self.api.pull["base"]["ref"] = "next" target = "c" * 40 self.api.next = target @@ -498,14 +765,34 @@ def test_next_reuses_target_snapshot_but_keeps_main_policy(self) -> None: with ( mock.patch.object(budget.report, "git", return_value=b"true\n"), mock.patch.object(budget, "evaluate", return_value=({}, b"")) as evaluate, + mock.patch.object( + budget, + "read_policy", + return_value={"next_policy": {"repository": REPOSITORY, "main_sha": self.base}}, + ), ): - self.evaluate(trusted_report=self.root) + budget.github_evaluate( + self.repo, + REPOSITORY, + {"repository": {"full_name": REPOSITORY}, "workflow_run": self.api.run}, + self.base, + self.root, + selection={ + "target_branch": "next", + "base_sha": target, + "head_sha": self.head, + "policy_sha": self.base, + "bootstrap": False, + "schema_version": 1, + }, + ) self.assertEqual(evaluate.call_args.args, (self.repo, target, self.head)) self.assertEqual(evaluate.call_args.kwargs["policy_sha"], self.base) def test_next_report_reuse_rejects_retargeting_and_policy_mismatch(self) -> None: self.api.pull["base"]["ref"] = "next" self.repo.mkdir() + self.api.next = "d" * 40 for field, value in ( ("target_branch", "main"), ("policy_sha", "c" * 40), @@ -523,9 +810,23 @@ def test_next_report_reuse_rejects_retargeting_and_policy_mismatch(self) -> None self.subTest(field=field), self.assertRaisesRegex(ValueError, "trusted report does not match"), ): - self.evaluate(trusted_report=self.root) - - def test_next_queue_uses_target_rules_and_rejects_stale_policy(self) -> None: + budget.github_evaluate( + self.repo, + REPOSITORY, + {"repository": {"full_name": REPOSITORY}, "workflow_run": self.api.run}, + self.base, + self.root, + selection={ + "target_branch": "next", + "base_sha": self.api.next, + "head_sha": self.head, + "policy_sha": self.base, + "bootstrap": False, + "schema_version": 1, + }, + ) + + def test_next_queue_uses_target_rules_and_requires_policy_selection(self) -> None: self.api.run = source_run(self.head, "merge_group") self.api.run["head_branch"] = "gh-readonly-queue/next/pr-3-test" self.api.rules = [] @@ -533,10 +834,34 @@ def test_next_queue_uses_target_rules_and_rejects_stale_policy(self) -> None: self.evaluate() self.api.rules = [{"type": "merge_queue"}] self.api.main = "c" * 40 - with self.assertRaisesRegex(ValueError, "trusted checkout is stale"): + with self.assertRaisesRegex(ValueError, "policy selection"): self.evaluate() self.assertFalse(self.repo.exists()) + def test_next_selection_receipt_rejects_missing_unknown_or_boolean_version(self) -> None: + self.api.pull["base"]["ref"] = "next" + self.api.next = "d" * 40 + for version in (None, 2, True): + with ( + self.subTest(version=version), + self.assertRaisesRegex(ValueError, "policy selection"), + ): + budget.github_evaluate( + self.repo, + REPOSITORY, + {"repository": {"full_name": REPOSITORY}, "workflow_run": self.api.run}, + self.base, + selection={ + "schema_version": version, + "target_branch": "next", + "base_sha": self.api.next, + "head_sha": self.head, + "policy_sha": self.base, + "bootstrap": False, + }, + ) + self.assertFalse(self.repo.exists()) + def test_queue_cannot_reuse_pr_measurement(self) -> None: self.api.run = source_run(self.head, "merge_group") with self.assertRaisesRegex(ValueError, "only PR runs can reuse"): diff --git a/scripts/castiron/test_custom_code_policy.py b/scripts/castiron/test_custom_code_policy.py new file mode 100644 index 0000000000..68c715440b --- /dev/null +++ b/scripts/castiron/test_custom_code_policy.py @@ -0,0 +1,587 @@ +# File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. +"""Current-main policy selection and review authorization regressions.""" + +from __future__ import annotations + +import copy +import json +import sys +import tempfile +import unittest +import unittest.mock as mock # noqa: PLR0402 - keep unittest imports consistent +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import custom_code_budget as budget +import custom_code_policy as selector +from custom_code_test_support import GitTestCase + +REPO = "openai/example" + + +class ReviewedPinTests(GitTestCase): + def setUp(self) -> None: # pyright: ignore[reportImplicitOverride] + super().setUp() + self.git("remote", "add", "origin", f"https://github.com/{REPO}.git") + self.policy = {"schema_version": 1, "max_custom_patch_lines": 4000} + self.write_policy(self.policy) + checker = self.repo / "scripts/castiron/custom_code_budget.py" + checker.parent.mkdir(parents=True) + checker.write_text("NEXT_POLICY_VERSION = 1\n") + self.main = self.commit("main policy") + self.git("update-ref", "refs/remotes/origin/main", self.main) + self.pin = {"repository": REPO, "main_sha": self.main} + self.write_policy({**self.policy, "next_policy": self.pin}) + self.base = self.commit("reviewed next pin") + self.git("checkout", "-q", "-b", "next", self.base) + (self.repo / "sdk.txt").write_text("candidate SDK bytes") + self.head = self.commit("sdk change") + + def write_policy(self, value: dict[str, Any]) -> None: + (self.repo / ".castiron-ratchet.json").write_text(json.dumps(value)) + + def select( + self, base: str | None = None, head: str | None = None, main: str | None = None, **kw: Any + ) -> dict[str, Any]: + return selector.select_policy( + self.repo, + REPO, + "next", + base or self.base, + head or self.head, + main or self.main, + **kw, + ) + + def review_api(self, reviews: list[dict[str, Any]]) -> Callable[[str, str], Any]: + def api(method: str, path: str) -> Any: + if "/commits/" in path: + return [{"number": 3}] + if "/reviews?" in path: + return reviews + return { + "number": 3, + "state": "open", + "head": {"sha": self.head}, + "base": {"ref": "next", "repo": {"full_name": REPO}}, + "user": {"login": "author"}, + } + + return api + + def approved(self, **kwargs: Any) -> dict[str, Any]: + return { + "id": 10, + "state": "APPROVED", + "commit_id": self.head, + "user": {"type": "User", "login": "reviewer"}, + "author_association": "MEMBER", + **kwargs, + } + + def test_only_base_pin_selects_checker(self) -> None: + (self.repo / "scripts/castiron/custom_code_budget.py").write_text( + 'raise RuntimeError("candidate code must never execute")\n' + ) + self.head = self.commit("malicious candidate code") + self.assertEqual(self.select()["policy_sha"], self.main) + self.assertEqual(self.select()["schema_version"], 1) + + def test_old_reviewed_pin_survives_main_advancing(self) -> None: + self.git("checkout", "-q", "--detach", self.main) + (self.repo / "main-new.txt").write_text("later main") + later = self.commit("main later") + self.git("update-ref", "refs/remotes/origin/main", later) + self.assertEqual(self.select(main=later)["policy_sha"], self.main) + + def test_wrong_repository_and_unadopted_pin_rejected(self) -> None: + for pin in ( + {"repository": "other/repo", "main_sha": self.main}, + {"repository": REPO, "main_sha": self.head}, + ): + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": pin}) + base = self.commit("bad pin") + with self.assertRaises((ValueError, selector.report.ReportError)): + self.select(base=base, head=base) + + def test_side_branch_merge_does_not_adopt_policy(self) -> None: + self.git("checkout", "-q", "--detach", self.main) + (self.repo / "side").write_text("side") + side = self.commit("side policy") + self.git("checkout", "-q", "--detach", self.main) + (self.repo / "parent").write_text("main") + self.commit("main") + self.git("merge", "--no-ff", "-qm", "merge side", side) + main = self.git("rev-parse", "HEAD") + self.git("update-ref", "refs/remotes/origin/main", main) + self.write_policy({**self.policy, "next_policy": {"repository": REPO, "main_sha": side}}) + base = self.commit("next pins side") + with self.assertRaisesRegex(ValueError, "first-parent"): + self.select(base=base, head=base, main=main) + + def test_absent_pin_ordinary_change_fails(self) -> None: + with self.assertRaisesRegex(ValueError, "initialization"): + self.select(base=self.main, head=self.head) + + def test_absent_pin_preserves_value_error(self) -> None: + with self.assertRaisesRegex(ValueError, "trusted next base has no reviewed policy pin"): + selector.next_policy_sha(self.repo, REPO, self.main, self.main) + + def test_malformed_base_pin_cannot_bootstrap(self) -> None: + self.git("checkout", "-q", "--detach", self.main) + self.write_policy({**self.policy, "next_policy": {}}) + base = self.commit("malformed") + with self.assertRaisesRegex(ValueError, "exactly"): + self.select(base=base, head=self.base) + + def test_bootstrap_requires_exact_policy_only_and_human(self) -> None: + self.head = self.base + with mock.patch.object( + selector.report, "api", side_effect=self.review_api([self.approved()]) + ): + result = self.select(base=self.main) + self.assertTrue(result["bootstrap"]) + self.assertEqual(result["policy_reviews"], [{"number": 3, "head_sha": self.head}]) + self.assertEqual(result["human_review_rules"], selector.HUMAN_REVIEW_RULES) + self.assertEqual(result["policy_sha"], self.main) + with ( + mock.patch.object(selector.report, "api", side_effect=self.review_api([])), + self.assertRaisesRegex(ValueError, "human"), + ): + self.select(base=self.main) + with self.assertRaisesRegex(ValueError, "policy-only"): + self.select(base=self.main, head=self.git("rev-parse", "next")) + + def test_queue_bootstrap_isolated_per_constituent(self) -> None: + self.git("checkout", "-q", "--detach", self.main) + (self.repo / "unrelated.txt").write_text("independent SDK change") + sdk_head = self.commit("unrelated PR on unpinned next") + self.git("merge", "--no-ff", "-qm", "queue initialization and SDK", self.base) + group = self.git("rev-parse", "HEAD") + with mock.patch.object(selector, "require_human_policy_review", return_value=3) as review: + result = self.select(base=self.main, head=group, pull_heads=[self.base, sdk_head]) + self.assertTrue(result["bootstrap"]) + self.assertEqual(result["policy_sha"], self.main) + self.assertEqual(result["policy_reviews"], [{"number": 3, "head_sha": self.base}]) + review.assert_called_once_with(REPO, self.base) + with ( + mock.patch.object( + selector, "require_human_policy_review", side_effect=ValueError("human required") + ), + self.assertRaisesRegex(ValueError, "human"), + ): + self.select(base=self.main, head=group, pull_heads=[self.base, sdk_head]) + for heads in (None, [group]): + with self.subTest(heads=heads), self.assertRaisesRegex(ValueError, "policy-only"): + self.select(base=self.main, head=group, pull_heads=heads) + with self.assertRaisesRegex(ValueError, "verified constituent"): + self.select(base=self.main, head=group, pull_heads=[]) + + def test_queue_pin_update_isolated_per_constituent(self) -> None: + self.git("checkout", "-q", "--detach", self.main) + (self.repo / "main-new.txt").write_text("later main") + later = self.commit("main later") + self.git("update-ref", "refs/remotes/origin/main", later) + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": {"repository": REPO, "main_sha": later}}) + pin_head = self.commit("isolated pin update") + self.git("merge", "--no-ff", "-qm", "queue group", self.head) + group = self.git("rev-parse", "HEAD") + with mock.patch.object(selector, "require_human_policy_review", return_value=3) as review: + result = self.select(head=group, main=later, pull_heads=[pin_head, self.head]) + self.assertEqual(result["policy_sha"], self.main) + self.assertEqual(result["policy_reviews"], [{"number": 3, "head_sha": pin_head}]) + review.assert_called_once_with(REPO, pin_head) + # A combined SDK/pin change in one PR is still forbidden. + with self.assertRaisesRegex(ValueError, "policy-only"): + self.select(head=group, main=later) + with self.assertRaisesRegex(ValueError, "policy-only"): + self.select(head=group, main=later, pull_heads=[group]) + + def advanced_pin_base(self) -> tuple[str, str]: + self.git("checkout", "-q", "--detach", self.main) + (self.repo / "later-main.txt").write_text("later main") + later = self.commit("later approved main") + self.git("update-ref", "refs/remotes/origin/main", later) + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": {"repository": REPO, "main_sha": later}}) + return later, self.commit("reviewed pin update on next") + + def test_stale_sdk_branch_uses_current_protected_pin(self) -> None: + later, base = self.advanced_pin_base() + with mock.patch.object(selector, "require_human_policy_review") as review: + result = self.select(base=base, main=later) + self.assertEqual(result["policy_sha"], later) + self.assertEqual(result["policy_reviews"], []) + review.assert_not_called() + + def test_queue_stale_sdk_constituent_inherits_protected_pin(self) -> None: + later, base = self.advanced_pin_base() + self.git("merge", "--no-ff", "-qm", "queue SDK branch", self.head) + group = self.git("rev-parse", "HEAD") + with mock.patch.object(selector, "require_human_policy_review") as review: + result = self.select(base=base, head=group, main=later, pull_heads=[self.head]) + self.assertEqual(result["policy_sha"], later) + self.assertEqual(result["policy_reviews"], []) + review.assert_not_called() + + def test_stale_actual_pin_edit_still_requires_isolation_and_human(self) -> None: + later, base = self.advanced_pin_base() + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": {"repository": REPO, "main_sha": later}}) + edited = self.commit("PR independently edits pin") + with mock.patch.object(selector, "require_human_policy_review", return_value=3) as review: + result = self.select(base=base, head=edited, main=later) + self.assertEqual(result["policy_reviews"], [{"number": 3, "head_sha": edited}]) + review.assert_called_once_with(REPO, edited) + with ( + mock.patch.object( + selector, "require_human_policy_review", side_effect=ValueError("human required") + ), + self.assertRaisesRegex(ValueError, "human"), + ): + self.select(base=base, head=edited, main=later) + (self.repo / "mixed-sdk.txt").write_text("SDK change") + mixed = self.commit("mix policy and SDK") + with self.assertRaisesRegex(ValueError, "policy-only"): + self.select(base=base, head=mixed, main=later) + with self.assertRaisesRegex(ValueError, "policy-only"): + self.select(base=base, head=mixed, main=later, pull_heads=[mixed]) + + def test_stale_sdk_budget_after_target_decrease_needs_no_review(self) -> None: + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": self.pin, "max_custom_patch_lines": 3000}) + base = self.commit("lower protected next budget") + self.git("merge", "--no-ff", "-qm", "queue stale SDK branch", self.head) + group = self.git("rev-parse", "HEAD") + for head, heads in ((self.head, None), (group, [self.head])): + with ( + self.subTest(head=head), + mock.patch.object(selector, "require_human_policy_review") as review, + ): + result = self.select(base=base, head=head, pull_heads=heads) + self.assertEqual(result["policy_sha"], self.main) + self.assertEqual(result["base_sha"], base) + self.assertEqual(result["policy_reviews"], []) + self.assertEqual(budget.read_budget(self.repo, base), 3000) + review.assert_not_called() + + def test_actual_budget_increase_is_reviewed_even_if_target_increased_further(self) -> None: + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": self.pin, "max_custom_patch_lines": 6000}) + base = self.commit("reviewed target budget increase") + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": self.pin, "max_custom_patch_lines": 5000}) + edited = self.commit("PR actually raises budget") + for heads in (None, [edited]): + with ( + mock.patch.object( + selector, + "require_human_policy_review", + side_effect=ValueError("human required"), + ), + self.assertRaisesRegex(ValueError, "human"), + ): + self.select(base=base, head=edited, pull_heads=heads) + (self.repo / "mixed-sdk.txt").write_text("SDK change") + mixed = self.commit("mix SDK and actual budget increase") + with self.assertRaisesRegex(ValueError, "policy-only"): + self.select(base=base, head=mixed) + + def test_selector_outputs_transport_review_receipt_as_json(self) -> None: + event = self.repo / "event.json" + event.write_text("{}") + out, output = self.repo / "selection.json", self.repo / "outputs" + selected = { + "schema_version": 1, + "policy_reviews": [{"number": 3, "head_sha": self.head}], + "human_review_rules": selector.HUMAN_REVIEW_RULES, + "bootstrap": False, + } + args = [ + "budget", + "select-policy", + "--repo", + str(self.repo / "objects.git"), + "--repository", + REPO, + "--event-path", + str(event), + "--trusted-sha", + self.main, + "--out", + str(out), + ] + with ( + mock.patch.object(sys, "argv", args), + mock.patch.dict(selector.os.environ, {"GITHUB_OUTPUT": str(output)}), + mock.patch.object(selector, "select_github_policy", return_value=selected), + ): + self.assertEqual(selector.main(), 0) + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual(json.loads(values["policy_reviews"]), selected["policy_reviews"]) + self.assertEqual(json.loads(values["human_review_rules"]), selector.HUMAN_REVIEW_RULES) + self.assertFalse(json.loads(values["bootstrap"])) + self.assertEqual(json.loads(out.read_text())["schema_version"], 1) + + def test_forged_bot_stale_dismissed_untrusted_reviews_rejected(self) -> None: + self.head = self.base + cases = [ + [], + [self.approved(state="COMMENTED", body="APPROVED by a human")], + [self.approved(user={"type": "Bot", "login": "reviewer[bot]"})], + [self.approved(commit_id="a" * 40)], + [self.approved(author_association="NONE")], + [ + self.approved( + user={"type": "User", "login": "dpiet-oai"}, + body="", + ) + ], + [ + self.approved( + user={"type": "User", "login": "markstuart-oai"}, + body="Reviewed `f4c5de3e28ea4c21c2a3d230d06341c3c25a0004`. No actionable findings in the two workflow changes. Adding `next` retains the existing CodeQL and exported-API jobs, uses the actual PR or merge-group comparison base, and leaves merge-group triggers and main-only release/credential guards intact.\n\nSource-only review; I did not execute candidate code or workflows. Current-head CodeQL and exported-API checks passed. Some hosted ecosystem and Node-version tests are still pending. Branch protection settings and the author's branch-creation claims were not independently verified; required CI and review gates still apply.", + ) + ], + [self.approved(body="Automated source review.")], + [self.approved(body="This does not substitute for human approval.")], + [self.approved(body="This does not constitute human approval.")], + [self.approved(body="This is not a human approving review.")], + [self.approved(), self.approved(id=11, state="DISMISSED")], + ] + for reviews in cases: + with ( + self.subTest(reviews=reviews), + mock.patch.object(selector.report, "api", side_effect=self.review_api(reviews)), + self.assertRaisesRegex(ValueError, "human"), + ): + self.select(base=self.main) + + def test_budget_increase_without_pin_change_requires_human(self) -> None: + self.git("checkout", "-q", "--detach", self.base) + policy = selector.read_policy(self.repo, self.base) + self.write_policy( + {**policy, "max_custom_patch_lines": policy["max_custom_patch_lines"] + 1} + ) + self.head = self.commit("increase budget without changing pin") + for reviews in ([], [self.approved(body="Automated source review.")]): + with ( + mock.patch.object(selector.report, "api", side_effect=self.review_api(reviews)), + self.assertRaisesRegex(ValueError, "human"), + ): + self.select() + with mock.patch.object( + selector.report, "api", side_effect=self.review_api([self.approved()]) + ): + self.assertEqual(self.select()["policy_sha"], self.main) + + def test_changed_authenticated_main_ref_rejected(self) -> None: + self.git("update-ref", "refs/remotes/origin/main", self.head) + with self.assertRaisesRegex(ValueError, "changed"): + self.select() + + def test_wrong_origin_rejected(self) -> None: + self.git("remote", "set-url", "origin", "https://github.com/attacker/example.git") + with self.assertRaisesRegex(ValueError, "origin"): + self.select() + + def test_policy_update_uses_old_base_checker_and_requires_human(self) -> None: + self.git("checkout", "-q", "--detach", self.main) + self.write_policy({**self.policy, "max_custom_patch_lines": 5000}) + newer = self.commit("new reviewed main policy") + self.git("update-ref", "refs/remotes/origin/main", newer) + self.git("checkout", "-q", "--detach", self.base) + self.write_policy({**self.policy, "next_policy": {"repository": REPO, "main_sha": newer}}) + self.head = self.commit("request new pin") + with mock.patch.object( + selector.report, "api", side_effect=self.review_api([self.approved()]) + ): + self.assertEqual(self.select(main=newer)["policy_sha"], self.main) + with ( + mock.patch.object(selector.report, "api", side_effect=self.review_api([])), + self.assertRaisesRegex(ValueError, "human"), + ): + self.select(main=newer) + + def test_incapable_policy_is_not_executed(self) -> None: + self.git("checkout", "-q", "--detach", self.main) + (self.repo / "scripts/castiron/custom_code_budget.py").write_text( + "raise RuntimeError('must not execute')\n" + ) + incapable = self.commit("old checker without pin protocol") + self.git("update-ref", "refs/remotes/origin/main", incapable) + self.write_policy( + {**self.policy, "next_policy": {"repository": REPO, "main_sha": incapable}} + ) + base = self.commit("pin incapable checker") + with self.assertRaisesRegex(ValueError, "does not support"): + self.select(base=base, head=base, main=incapable) + + def test_main_selection_unchanged(self) -> None: + result = selector.select_policy(self.repo, REPO, "main", self.main, self.head, self.main) + self.assertEqual(result["policy_sha"], self.main) + self.assertFalse(result["bootstrap"]) + + +POLICY_SOURCE_ROOT = "repos/" + REPO +CANONICAL_POLICY_WORKFLOW = ".github/workflows/castiron-custom-code.yml" +CI_POLICY_WORKFLOW = ".github/workflows/ci.yml" + + +class PolicySourceWorkflowTests(unittest.TestCase): + def setUp(self) -> None: # pyright: ignore[reportImplicitOverride] + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.repo = Path(self.temp.name) / "objects" + self.workflow_run: dict[str, Any] = { + "id": 42, + "workflow_id": 456, + "repository": {"full_name": REPO}, + "head_sha": "a" * 40, + "status": "completed", + "event": "pull_request", + "run_attempt": 3, + "path": CANONICAL_POLICY_WORKFLOW + "@refs/pull/12/merge", + } + self.event: dict[str, Any] = { + "repository": {"full_name": REPO}, + "workflow_run": copy.deepcopy(self.workflow_run), + } + self.branch = "next" + self.select = self.start_patch( + mock.patch.object(selector, "select_policy", return_value={"selected": True}) + ) + self.start_patch(mock.patch.object(selector.report, "api", side_effect=self.api)) + self.start_patch( + mock.patch.object(selector.report, "current_pull", side_effect=self.current_pull) + ) + self.start_patch( + mock.patch.object( + selector.report, + "git", + side_effect=self.git_result, + ) + ) + + def current_pull(self, *args: Any) -> tuple[int, str]: + return 12, self.branch + + def git_result(self, repo: Path, *args: str) -> bytes: + return b"b" * 40 if args[0] == "rev-parse" else b"" + + def start_patch(self, patcher: Any) -> Any: + result = patcher.start() + self.addCleanup(patcher.stop) + return result + + def api(self, method: str, path: str, *args: Any) -> Any: + return { + POLICY_SOURCE_ROOT: {"default_branch": "main"}, + POLICY_SOURCE_ROOT + "/actions/runs/42": self.workflow_run, + POLICY_SOURCE_ROOT + "/actions/workflows/castiron-custom-code.yml": { + "id": 456, + "path": CANONICAL_POLICY_WORKFLOW, + }, + POLICY_SOURCE_ROOT + "/actions/workflows/ci.yml": { + "id": 456, + "path": CI_POLICY_WORKFLOW, + }, + POLICY_SOURCE_ROOT + "/git/ref/heads/main": {"object": {"sha": "c" * 40}}, + POLICY_SOURCE_ROOT + "/git/ref/heads/next": {"object": {"sha": "b" * 40}}, + }[path] + + def call(self, **kwargs: Any) -> dict[str, Any]: + return selector.select_github_policy(self.repo, REPO, self.event, "c" * 40, **kwargs) + + def use_ci(self) -> None: + self.workflow_run["path"] = CI_POLICY_WORKFLOW + "@refs/pull/12/merge" + + def test_lookalike_workflow_cannot_select_policy(self) -> None: + self.workflow_run["path"] = CANONICAL_POLICY_WORKFLOW + "@evil.yml" + self.workflow_run["workflow_id"] = 789 + with self.assertRaisesRegex(ValueError, "canonical workflow"): + self.call() + self.select.assert_not_called() + + def test_default_canonical_unchanged(self) -> None: + self.branch = "main" + self.assertEqual(self.call(), {"selected": True}) + + def test_explicit_ci_uses_same_base_pin_selector(self) -> None: + self.use_ci() + self.assertEqual(self.call(source_workflow=CI_POLICY_WORKFLOW), {"selected": True}) + self.select.assert_called_once_with( + self.repo, REPO, "next", "b" * 40, "a" * 40, "c" * 40, pull_heads=None + ) + + def test_candidate_event_cannot_select_ci_adapter(self) -> None: + self.use_ci() + self.event["source_workflow"] = CI_POLICY_WORKFLOW + with self.assertRaisesRegex(ValueError, "source run"): + self.call() + self.select.assert_not_called() + + def test_unknown_workflow_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "source workflow"): + self.call(source_workflow=".github/workflows/evil.yml") + self.select.assert_not_called() + + def test_ci_adapter_cannot_retarget_main(self) -> None: + self.use_ci() + self.branch = "main" + with self.assertRaisesRegex(ValueError, "restricted to next"): + self.call(source_workflow=CI_POLICY_WORKFLOW) + self.select.assert_not_called() + + def test_wrong_actual_workflow(self) -> None: + with self.assertRaisesRegex(ValueError, "source run"): + self.call(source_workflow=CI_POLICY_WORKFLOW) + self.select.assert_not_called() + + def test_stale_or_mismatched_authenticated_identity(self) -> None: + self.use_ci() + for field, value in [ + ("head_sha", "d" * 40), + ("run_attempt", 4), + ("status", "in_progress"), + ("event", "push"), + ("repository", {"full_name": "attacker/example"}), + ]: + with self.subTest(field=field): + old = self.workflow_run[field] + self.workflow_run[field] = value + with self.assertRaisesRegex(ValueError, "source run"): + self.call(source_workflow=CI_POLICY_WORKFLOW) + self.workflow_run[field] = old + self.select.assert_not_called() + + +class SelectorInterfaceTests(unittest.TestCase): + def test_budget_cli_forwards_to_adjacent_selector(self) -> None: + args = ["select-policy", "--repo", "objects", "--repository", REPO] + with ( + mock.patch.object(sys, "argv", ["budget", *args]), + mock.patch.object(budget.policy, "main", return_value=0) as select, + ): + self.assertEqual(budget.main(), 0) + select.assert_called_once_with(args) + + def test_isolated_cli_loads_only_adjacent_modules(self) -> None: + import subprocess + + for module in (budget, selector): + assert module.__file__ is not None + completed = subprocess.run( + [sys.executable, "-I", module.__file__, "select-policy", "--help"], + capture_output=True, + text=True, + check=True, + ) + self.assertIn("--source-workflow", completed.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/castiron/test_custom_code_publication.py b/scripts/castiron/test_custom_code_publication.py index 475cc7dc7d..eeb1331dcb 100644 --- a/scripts/castiron/test_custom_code_publication.py +++ b/scripts/castiron/test_custom_code_publication.py @@ -12,6 +12,7 @@ from typing import Any, cast from unittest import mock +import custom_code_policy as policy import custom_code_report as report from custom_code_test_support import GitTestCase, source_run @@ -28,9 +29,13 @@ def run_publisher(step: str, payload: dict[str, Any]) -> list[dict[str, Any]]: if line and not line.startswith(" "): break lines.append(line[12:]) + return run_workflow_script("\n".join(lines), payload) + + +def run_workflow_script(script: str, payload: dict[str, Any]) -> list[dict[str, Any]]: output = subprocess.run( ["node", str(Path(__file__).with_name("fixtures") / "github_publisher.cjs")], - input=json.dumps({**payload, "script": "\n".join(lines)}), + input=json.dumps({**payload, "script": script}), text=True, capture_output=True, check=True, @@ -39,11 +44,86 @@ def run_publisher(step: str, payload: dict[str, Any]) -> list[dict[str, Any]]: class WorkflowTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("node"), "GitHub Actions JavaScript runtime") + def test_workflow_path_filters_accept_refs_without_accepting_other_files(self) -> None: + compute = WORKFLOW.read_text().split("\n compute:\n", 1)[1] + condition = next( + line.removeprefix(" if: ") + for line in compute.splitlines() + if line.startswith(" if: ") + ) + expected_path = ".github/workflows/castiron-custom-code.yml" + cases = [ + (expected_path, True), + (expected_path + "@main", True), + (expected_path + "@refs/heads/next", True), + (expected_path + "@refs/pull/42/merge", True), + (".github/workflows/other.yml@main", False), + (expected_path + ".bak@main", False), + (expected_path + "/other@main", False), + ("other/" + expected_path + "@main", False), + ("", False), + ] + head = "a" * 40 + pull = { + "number": 3, + "state": "open", + "head": {"sha": head}, + "base": {"ref": "next", "repo": {"full_name": "openai/example"}}, + } + for path, accepted in cases: + for event in ("pull_request", "merge_group", "push"): + with self.subTest(path=path, event=event): + run = {**source_run(head), "id": 20, "path": path, "event": event} + payload = { + "context": { + "payload": {"workflow_run": run}, + "runId": 20, + "repo": {"owner": "openai", "repo": "example"}, + "serverUrl": "https://github.com", + }, + "current": pull, + } + # Evaluate the emitted job condition with its Actions builtins. + script = ( + "const fromJSON = JSON.parse;\n" + "const contains = (items, value) => items.includes(value);\n" + "const startsWith = (value, prefix) => value.startsWith(prefix);\n" + "github.event = context.payload;\n" + f"if ({condition}) await github.rest.issues.createComment({{}});" + ) + self.assertEqual( + bool(run_workflow_script(script, payload)), + accepted and event in ("pull_request", "merge_group"), + ) + self.assertEqual( + bool(run_publisher("Publish a trusted failure status", payload)), + accepted and event == "pull_request", + ) + + def test_candidate_tests_cannot_modify_credentialed_reporter_runner(self) -> None: + producer = (WORKFLOW.parent / "castiron-custom-code.yml").read_text() + tests, reporter = producer.split("\n report:\n", 1) + tests = tests.split("\n candidate-tests:\n", 1)[1] + self.assertIn("runs-on: ubuntu-latest", tests) + self.assertIn("persist-credentials: false", tests) + self.assertIn("python3 -m unittest discover", tests) + self.assertNotIn("GH_TOKEN", tests) + self.assertNotIn("upload-artifact@", tests) + self.assertNotIn("unittest", reporter) + self.assertIn("GH_TOKEN:", reporter) + self.assertNotIn("needs: candidate-tests", reporter) + @unittest.skipUnless(shutil.which("node"), "GitHub Actions JavaScript runtime") def test_trusted_failure_publisher_updates_one_current_comment(self) -> None: head = "a" * 40 run = {**source_run(head), "id": 20} - current = {"state": "open", "head": {"sha": head}} + current = { + "number": 3, + "state": "open", + "head": {"sha": head}, + "base": {"ref": "main", "repo": {"full_name": "openai/example"}}, + } previous = { "id": 42, "user": {"type": "Bot", "login": "github-actions[bot]"}, @@ -84,6 +164,50 @@ def test_trusted_failure_publisher_updates_one_current_comment(self) -> None: self.assertIn("Report unavailable", writes[0]["body"]) self.assertIn("castiron:run:v1:20:1", writes[0]["body"]) + @unittest.skipUnless(shutil.which("node"), "GitHub Actions JavaScript runtime") + def test_failure_comments_require_one_supported_target(self) -> None: + head = "a" * 40 + + def pull(number: int, branch: str = "main", repo: str = "openai/example") -> dict[str, Any]: + return { + "number": number, + "state": "open", + "head": {"sha": head}, + "base": {"ref": branch, "repo": {"full_name": repo}}, + } + + cases: list[tuple[str, list[dict[str, Any]], list[int]]] = [ + ("retargeted", [pull(3, "release")], []), + ("other repository", [pull(3, repo="other/example")], []), + ("ambiguous main", [pull(3), pull(4)], []), + ("ambiguous next", [pull(3, "next"), pull(4, "next")], []), + ("prefer main", [pull(3, "next"), pull(4)], [4]), + ("one next", [pull(3, "next")], [3]), + ("ignore unsupported", [pull(3, "release"), pull(4)], [4]), + ] + for name, pulls, expected in cases: + for fallback in (False, True): + with self.subTest(name=name, fallback=fallback): + run = { + **source_run(head), + "id": 20, + "pull_requests": [] if fallback else pulls, + } + writes = run_publisher( + "Publish a trusted failure status", + { + "context": { + "payload": {"workflow_run": run}, + "runId": 20, + "repo": {"owner": "openai", "repo": "example"}, + "serverUrl": "https://github.com", + }, + "pulls_by_number": {str(pr["number"]): pr for pr in pulls}, + "fallback_pulls": pulls, + }, + ) + self.assertEqual([write["issue_number"] for write in writes], expected) + def test_workflow_reports_all_branches_without_write_credentials(self) -> None: workflows = Path(__file__).resolve().parents[2] / ".github/workflows" producer = (workflows / "castiron-custom-code.yml").read_text() @@ -105,8 +229,8 @@ def test_workflow_reports_all_branches_without_write_credentials(self) -> None: self.assertIn("pull-requests: read", compute) self.assertIn(" trusted-report ", compute) self.assertIn("ref: main", compute) - self.assertIn('--base "$(git rev-parse HEAD)"', compute) - self.assertIn("trusted_sha=$(git rev-parse HEAD)", compute) + self.assertIn('--base "$(git -C .castiron-policy-checker rev-parse HEAD)"', compute) + self.assertIn("trusted_sha=$(git -C .castiron-policy-checker rev-parse HEAD)", compute) self.assertIn("merge_group:", producer) self.assertNotIn("download-artifact@", compute) self.assertNotIn("unittest", compute) @@ -323,19 +447,26 @@ def publish( branch: str = "main", evaluated_branch: str | None = None, policy_changed: bool = False, + policy_sha: str | None = None, + selected_policy_matches: bool = True, queue_absent: bool = False, queue_pages: list[dict[str, Any]] | None = None, retarget_during_publication: bool = False, same_head_next_pr: bool = False, + policy_reviews: list[dict[str, Any]] | None = None, + reviews: list[dict[str, Any]] | None = None, + policy_pull: dict[str, Any] | None = None, + missing_review_contract: bool = False, ) -> list[dict[str, Any]]: base, head = "a" * 40, "b" * 40 run = source_run(head, event_name) if event_name == "merge_group": run["head_branch"] = f"gh-readonly-queue/{branch}/pr-7-example" payload: dict[str, Any] = { + "reviews": reviews or [], "main_sha": "d" * 40 if policy_changed - else ("c" * 40 if branch == "main" and base_changed else base), + else (policy_sha or ("c" * 40 if branch == "main" and base_changed else base)), "queue_absent": queue_absent, "queue_pages": queue_pages, "fallback_pulls": fallback_pulls, @@ -352,6 +483,7 @@ def publish( "run": {**run, **(run_overrides or {})}, "current": { "number": 3, + "user": {"login": "author"}, "state": "open", "head": {"sha": "c" * 40 if head_changed else head}, "base": { @@ -361,8 +493,18 @@ def publish( }, }, "env": { + "POLICY_REVIEWS": json.dumps(policy_reviews or []), + "HUMAN_REVIEW_RULES": "" + if missing_review_contract + else json.dumps(policy.HUMAN_REVIEW_RULES), + "SELECTED_BRANCH": branch, + "SELECTED_BASE_SHA": base, + "SELECTED_HEAD_SHA": head, + "SELECTED_POLICY_SHA": (policy_sha or base) + if selected_policy_matches + else "f" * 40, "TARGET_BRANCH": "" if no_result else (evaluated_branch or branch), - "POLICY_SHA": "" if no_result else base, + "POLICY_SHA": "" if no_result else (policy_sha or base), "BASE_SHA": "" if no_result else base, "HEAD_SHA": "" if no_result else head, "ISOLATION_RESULT": "success", @@ -370,6 +512,8 @@ def publish( "PUBLISH_ATTEMPT": "1", }, } + if policy_pull: + payload["pulls_by_number"] = {policy_pull["number"]: policy_pull} if same_head_next_pr: current = payload["current"] payload["pulls_by_number"] = { @@ -384,6 +528,132 @@ def publish( ] return run_publisher("Publish exact-head statuses after checking freshness", payload) + def test_live_human_review_predicate_matches_selector(self) -> None: + head = "b" * 40 + approved = { + "id": 10, + "state": "APPROVED", + "commit_id": head, + "user": {"login": "human", "type": "User"}, + "author_association": "MEMBER", + "body": "", + } + cases = [ + [approved], + [], + [{**approved, "state": "DISMISSED"}], + [approved, {**approved, "id": 11, "state": "CHANGES_REQUESTED"}], + [approved, {**approved, "id": 11, "state": "COMMENTED"}], + [{**approved, "commit_id": "f" * 40}], + [{**approved, "user": {"login": "author", "type": "User"}}], + [{**approved, "user": {"login": "bot", "type": "Bot"}}], + [{**approved, "author_association": "NONE"}], + ] + for body in [ + "", + "Automated source review.", + "Source-only review; I did not execute candidate code or workflows.", + "This does not substitute for human approval.", + "This is not a human approving review.", + ]: + cases.append([{**approved, "body": body}]) + for reviews in cases: + with self.subTest(reviews=reviews): + expected = ( + "success" if policy.has_human_approval(reviews, "author", head) else "failure" + ) + writes = self.publish( + branch="next", policy_reviews=[{"number": 3, "head_sha": head}], reviews=reviews + ) + self.assertEqual([w["state"] for w in writes], [expected, expected]) + + def test_queue_policy_approval_dismissal_after_selection_fails(self) -> None: + candidate = "e" * 40 + pull: dict[str, Any] = { + "number": 7, + "user": {"login": "author"}, + "state": "open", + "head": {"sha": candidate}, + "base": {"ref": "next", "repo": {"full_name": "openai/example"}}, + } + approved = { + "id": 1, + "state": "APPROVED", + "commit_id": candidate, + "user": {"login": "human", "type": "User"}, + "author_association": "MEMBER", + "body": "", + } + for state in ("APPROVED", "DISMISSED", "CHANGES_REQUESTED"): + with self.subTest(state=state): + writes = self.publish( + event_name="merge_group", + branch="next", + policy_pull=pull, + policy_reviews=[{"number": 7, "head_sha": candidate}], + reviews=[{**approved, "state": state}], + ) + self.assertEqual( + [w["state"] for w in writes], + ["success" if state == "APPROVED" else "failure"] * 2, + ) + # Ordinary automated approvals may keep the queue live, but cannot replace + # the independently required human policy approval. + writes = self.publish( + event_name="merge_group", + branch="next", + policy_pull=pull, + policy_reviews=[{"number": 7, "head_sha": candidate}], + reviews=[ + {**approved, "state": "DISMISSED"}, + { + **approved, + "id": 2, + "user": {"login": "automated", "type": "User"}, + "body": "", + }, + ], + ) + self.assertEqual([w["state"] for w in writes], ["failure", "failure"]) + + def test_queue_policy_constituent_changes_fail_closed(self) -> None: + candidate = "e" * 40 + pull: dict[str, Any] = { + "number": 7, + "user": {"login": "author"}, + "state": "open", + "head": {"sha": candidate}, + "base": {"ref": "next", "repo": {"full_name": "openai/example"}}, + } + approved = { + "id": 1, + "state": "APPROVED", + "commit_id": candidate, + "user": {"login": "human", "type": "User"}, + "author_association": "MEMBER", + "body": "", + } + for changed in [ + {**pull, "state": "closed"}, + {**pull, "head": {"sha": "f" * 40}}, + {**pull, "base": {**pull["base"], "ref": "main"}}, + ]: + with self.subTest(changed=changed): + writes = self.publish( + event_name="merge_group", + branch="next", + policy_pull=changed, + policy_reviews=[{"number": 7, "head_sha": candidate}], + reviews=[approved], + ) + self.assertEqual([w["state"] for w in writes], ["failure", "failure"]) + + def test_missing_policy_review_contract_fails_closed(self) -> None: + self.assertEqual( + [w["state"] for w in self.publish(branch="next", missing_review_contract=True)], + ["failure", "failure"], + ) + def test_statuses_attach_to_candidate_not_main(self) -> None: for event in ("pull_request", "merge_group"): with self.subTest(event=event): @@ -402,7 +672,10 @@ def test_next_cannot_overwrite_main_results(self) -> None: # Both evaluations use the same commit. A next success # must not replace the main target's required failure. result = self.publish(event_name=event, branch="next", previous_statuses=main) - self.assertEqual(result, []) + self.assertEqual([r["state"] for r in result], ["success", "success"]) + self.assertTrue( + {r["context"] for r in result}.isdisjoint(r["context"] for r in main) + ) def test_same_head_next_pr_does_not_block_required_main_statuses(self) -> None: for numbers in ([3, 4], [4, 3]): @@ -415,18 +688,35 @@ def test_same_head_next_pr_does_not_block_required_main_statuses(self) -> None: ) self.assertEqual([r["state"] for r in results], ["success", "failure"]) self.assertTrue(all(r["sha"] == "b" * 40 for r in results)) - self.assertEqual( - self.publish(run_overrides={"pull_requests": [{"number": 4}]}, same_head_next_pr=True), - [], + next_results = self.publish( + branch="next", run_overrides={"pull_requests": [{"number": 4}]}, same_head_next_pr=True ) + self.assertEqual([r["state"] for r in next_results], ["success", "success"]) + self.assertTrue(all(r["context"].startswith("Castiron next /") for r in next_results)) - def test_next_queue_never_authorizes_against_a_stale_main_policy(self) -> None: + def test_next_queue_approved_pin_survives_main_advancing(self) -> None: for changed in (False, True): with self.subTest(policy_changed=changed): - self.assertEqual( - self.publish(event_name="merge_group", branch="next", policy_changed=changed), - [], + results = self.publish( + event_name="merge_group", branch="next", policy_changed=changed ) + self.assertEqual([r["state"] for r in results], ["success", "success"]) + + def test_next_pin_receipt_and_target_freshness(self) -> None: + for event in ("pull_request", "merge_group"): + cases: tuple[dict[str, Any], ...] = ( + {"selected_policy_matches": False}, + {"no_result": True}, + {"base_changed": True}, + ) + for kwargs in cases: + with self.subTest(event=event, kwargs=kwargs): + results = self.publish(event_name=event, branch="next", **kwargs) + self.assertEqual([r["state"] for r in results], ["failure", "failure"]) + results = self.publish(event_name=event, branch="next", policy_sha="e" * 40) + self.assertEqual([r["state"] for r in results], ["success", "success"]) + results = self.publish(event_name="merge_group", branch="next", queue_absent=True) + self.assertEqual([r["state"] for r in results], ["failure", "failure"]) def test_main_statuses_bind_target_policy_and_queue_membership(self) -> None: for event in ("pull_request", "merge_group"): @@ -447,7 +737,7 @@ def test_next_fork_and_unsupported_targets(self) -> None: result = self.publish( branch="next", run_overrides={"pull_requests": []}, fallback_pulls=[{"number": 3}] ) - self.assertEqual(result, []) + self.assertEqual([r["state"] for r in result], ["success", "success"]) for event in ("pull_request", "merge_group"): for branch in ("Next", "Main", "release", "next-other"): with self.subTest(event=event, branch=branch): @@ -512,6 +802,22 @@ def test_missing_evaluation_publishes_actionable_failures(self) -> None: self.assertIn("inspect the trusted run", result["description"]) self.assertTrue(result["target_url"].endswith("/actions/runs/123")) + def test_main_rejects_mismatched_selector_receipt(self) -> None: + results = self.publish(selected_policy_matches=False) + self.assertTrue(results) + self.assertTrue(all(result["state"] != "success" for result in results)) + + def test_lookalike_workflow_cannot_publish(self) -> None: + self.assertEqual( + self.publish( + run_overrides={ + "path": ".github/workflows/castiron-custom-code.yml@evil.yml", + "workflow_id": 789, + } + ), + [], + ) + def test_superseded_or_wrong_source_run_cannot_publish(self) -> None: for overrides in ( {"run_attempt": 2}, diff --git a/scripts/castiron/test_custom_code_report.py b/scripts/castiron/test_custom_code_report.py index c288add152..c523765a10 100644 --- a/scripts/castiron/test_custom_code_report.py +++ b/scripts/castiron/test_custom_code_report.py @@ -242,10 +242,13 @@ def test_next_checkpoint_cannot_authorize_main_reports(self) -> None: measured["checks"]["budget"]["state"], "success" if target == "next" else "failure", ) - output = self.repo / "budget-output" - budget.write_result(output, measured) - self.assertEqual(json.loads((output / "budget.json").read_text())["target_branch"], "next") - self.assertIn("Target next:", (output / "summary.md").read_text()) + if target == "next": + output = self.repo / "budget-output" + budget.write_result(output, measured) + self.assertEqual( + json.loads((output / "budget.json").read_text())["target_branch"], "next" + ) + self.assertIn("Target next:", (output / "summary.md").read_text()) snapshot = report.create_public_snapshot( self.repo, self.git("rev-parse", f"{checkpoint}^{{tree}}"), diff --git a/src/openai/_client.py b/src/openai/_client.py index af535ac258..f656e00012 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, Any, Mapping, Callable, Awaitable +from typing import TYPE_CHECKING, Any, Mapping, Callable, Iterable, Awaitable, cast from typing_extensions import Self, Unpack, override import httpx2 @@ -13,6 +13,7 @@ from .auth import WorkloadIdentity, WorkloadIdentityAuth, X509WorkloadIdentity from ._types import ( Omit, + Query, Headers, Timeout, NotGiven, @@ -20,6 +21,7 @@ ProxiesTypes, HttpxSendArgs, RequestOptions, + omit, not_given, ) from ._utils import ( @@ -50,6 +52,7 @@ DEFAULT_MAX_RETRIES, SyncAPIClient, AsyncAPIClient, + _merge_mappings, ) from ._data_residency import DataResidency, resolve_data_residency @@ -161,6 +164,24 @@ def base_url(self, url: httpx2.URL | str) -> None: self._base_url_was_default = False self._data_residency = None + def _websocket_query_params_fallback(self, url: httpx2.URL, extra_query: Query) -> httpx2.QueryParams: + # Remove replaced logical defaults before encoding nested query values. + overridden = dict.fromkeys(extra_query, omit) + defaults = _merge_mappings(self.default_query, overridden) + params = httpx2.QueryParams(self.qs.stringify(cast(Any, defaults))).merge(url.params) + params = httpx2.QueryParams([(key, value) for key, value in params.multi_items() if key not in overridden]) + if isinstance(extra_query, httpx2.QueryParams): + return params.merge(extra_query) + # Per-call None and sequences retain blank values and repeated keys; + # defaults continue to use the REST encoder's configured array format. + values = _merge_mappings({}, extra_query) + for key, value in values.items(): + if value is None: + values[key] = "" + elif isinstance(value, (list, tuple)): + values[key] = ["" if item is None else item for item in cast(Iterable[object], value)] + return params.merge(self.qs.stringify(cast(Any, values), array_format="repeat")) + def __init__( self, *, @@ -927,6 +948,24 @@ def base_url(self, url: httpx2.URL | str) -> None: self._base_url_was_default = False self._data_residency = None + def _websocket_query_params_fallback(self, url: httpx2.URL, extra_query: Query) -> httpx2.QueryParams: + # Remove replaced logical defaults before encoding nested query values. + overridden = dict.fromkeys(extra_query, omit) + defaults = _merge_mappings(self.default_query, overridden) + params = httpx2.QueryParams(self.qs.stringify(cast(Any, defaults))).merge(url.params) + params = httpx2.QueryParams([(key, value) for key, value in params.multi_items() if key not in overridden]) + if isinstance(extra_query, httpx2.QueryParams): + return params.merge(extra_query) + # Per-call None and sequences retain blank values and repeated keys; + # defaults continue to use the REST encoder's configured array format. + values = _merge_mappings({}, extra_query) + for key, value in values.items(): + if value is None: + values[key] = "" + elif isinstance(value, (list, tuple)): + values[key] = ["" if item is None else item for item in cast(Iterable[object], value)] + return params.merge(self.qs.stringify(cast(Any, values), array_format="repeat")) + def __init__( self, *, diff --git a/src/openai/lib/responses_websocket/README.md b/src/openai/lib/responses_websocket/README.md index ef046787bf..2f32833f90 100644 --- a/src/openai/lib/responses_websocket/README.md +++ b/src/openai/lib/responses_websocket/README.md @@ -22,16 +22,19 @@ limits = ResponsesWebSocketLimits( max_response_bytes=None, # Default: no cumulative response accumulation cap. ) + async def run(): async with AsyncOpenAI() as client: async with client.responses.connect(websocket_connection_options={"max_size": None}) as connection: async with AsyncResponsesWebSocketSession(connection, limits=limits) as session: lane = session.lane("conversation") - await lane.send({ - "type": "response.create", - "model": "gpt-4o-mini", - "input": "Say hello.", - }) + await lane.send( + { + "type": "response.create", + "model": "gpt-4o-mini", + "input": "Say hello.", + } + ) response = await lane.get_final_response() return response ``` diff --git a/src/openai/resources/beta/agents/environments/files.py b/src/openai/resources/beta/agents/environments/files.py index f728bd42ed..962f0f7a1f 100644 --- a/src/openai/resources/beta/agents/environments/files.py +++ b/src/openai/resources/beta/agents/environments/files.py @@ -33,6 +33,25 @@ class Files(SyncAPIResource): + @cached_property + def with_raw_response(self) -> FilesWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/openai/openai-python#accessing-raw-response-data-eg-headers + """ + return FilesWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FilesWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/openai/openai-python#with_streaming_response + """ + return FilesWithStreamingResponse(self) + def prepare( self, files: Mapping[str, str | PathLike[str]], @@ -42,7 +61,7 @@ def prepare( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> PreparedAgentFiles: - """Beta: upload selected files for a new hosted environment.""" + "Beta: upload selected files for a new hosted environment." return prepare( self, files, @@ -60,9 +79,13 @@ def prepare_directory( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> PreparedAgentFiles: - """Beta: prepare an explicitly selected directory snapshot.""" + "Beta: prepare an explicitly selected directory snapshot." return self.prepare( - directory_files(root, destination, include), + directory_files( + root, + destination, + include, + ), extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, @@ -80,7 +103,7 @@ def upload( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> StagedAgentFile: - """Beta: upload and stage one file in an existing environment.""" + "Beta: upload and stage one file in an existing environment." return upload( self, environment_id, @@ -89,25 +112,6 @@ def upload( {"extra_headers": extra_headers, "extra_query": extra_query, "extra_body": extra_body, "timeout": timeout}, ) - @cached_property - def with_raw_response(self) -> FilesWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/openai/openai-python#accessing-raw-response-data-eg-headers - """ - return FilesWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> FilesWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/openai/openai-python#with_streaming_response - """ - return FilesWithStreamingResponse(self) - @overload def create( self, @@ -291,6 +295,25 @@ def list( class AsyncFiles(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncFilesWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/openai/openai-python#accessing-raw-response-data-eg-headers + """ + return AsyncFilesWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFilesWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/openai/openai-python#with_streaming_response + """ + return AsyncFilesWithStreamingResponse(self) + async def prepare( self, files: Mapping[str, str | PathLike[str]], @@ -300,7 +323,7 @@ async def prepare( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> PreparedAgentFiles: - """Beta: upload selected files for a new hosted environment.""" + "Beta: upload selected files for a new hosted environment." return await async_prepare( self, files, @@ -318,9 +341,13 @@ async def prepare_directory( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> PreparedAgentFiles: - """Beta: prepare an explicitly selected directory snapshot.""" + "Beta: prepare an explicitly selected directory snapshot." return await self.prepare( - await async_directory_files(root, destination, include), + await async_directory_files( + root, + destination, + include, + ), extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, @@ -338,7 +365,7 @@ async def upload( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> StagedAgentFile: - """Beta: upload and stage one file in an existing environment.""" + "Beta: upload and stage one file in an existing environment." return await async_upload( self, environment_id, @@ -347,25 +374,6 @@ async def upload( {"extra_headers": extra_headers, "extra_query": extra_query, "extra_body": extra_body, "timeout": timeout}, ) - @cached_property - def with_raw_response(self) -> AsyncFilesWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/openai/openai-python#accessing-raw-response-data-eg-headers - """ - return AsyncFilesWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncFilesWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/openai/openai-python#with_streaming_response - """ - return AsyncFilesWithStreamingResponse(self) - @overload async def create( self, diff --git a/src/openai/resources/beta/agents/sessions/artifacts.py b/src/openai/resources/beta/agents/sessions/artifacts.py index d59df19d31..cd4a7e4415 100644 --- a/src/openai/resources/beta/agents/sessions/artifacts.py +++ b/src/openai/resources/beta/agents/sessions/artifacts.py @@ -32,10 +32,6 @@ class Artifacts(SyncAPIResource): - def for_result(self, result: AgentTurnResult) -> AgentResultArtifacts: - """Beta: download artifacts scoped to the exact completed result turn.""" - return AgentResultArtifacts(self, result) - @cached_property def with_raw_response(self) -> ArtifactsWithRawResponse: """ @@ -55,6 +51,16 @@ def with_streaming_response(self) -> ArtifactsWithStreamingResponse: """ return ArtifactsWithStreamingResponse(self) + def for_result( + self, + result: AgentTurnResult, + ) -> AgentResultArtifacts: + "Beta: download artifacts scoped to the exact completed result turn." + return AgentResultArtifacts( + self, + result, + ) + def retrieve( self, artifact_id: str, @@ -260,10 +266,6 @@ def content( class AsyncArtifacts(AsyncAPIResource): - def for_result(self, result: AgentTurnResult) -> AsyncAgentResultArtifacts: - """Beta: download artifacts scoped to the exact completed result turn.""" - return AsyncAgentResultArtifacts(self, result) - @cached_property def with_raw_response(self) -> AsyncArtifactsWithRawResponse: """ @@ -283,6 +285,16 @@ def with_streaming_response(self) -> AsyncArtifactsWithStreamingResponse: """ return AsyncArtifactsWithStreamingResponse(self) + def for_result( + self, + result: AgentTurnResult, + ) -> AsyncAgentResultArtifacts: + "Beta: download artifacts scoped to the exact completed result turn." + return AsyncAgentResultArtifacts( + self, + result, + ) + async def retrieve( self, artifact_id: str, diff --git a/src/openai/resources/beta/beta.py b/src/openai/resources/beta/beta.py index 0fd481d168..a76ab3a61d 100644 --- a/src/openai/resources/beta/beta.py +++ b/src/openai/resources/beta/beta.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ..chat import Chat, AsyncChat from ..._compat import cached_property from .assistants import ( Assistants, @@ -36,11 +37,7 @@ ThreadsWithStreamingResponse, AsyncThreadsWithStreamingResponse, ) -from ...resources.chat import Chat, AsyncChat -from .realtime.realtime import ( - Realtime, - AsyncRealtime, -) +from .realtime.realtime import Realtime, AsyncRealtime from .responses.responses import ( Responses, AsyncResponses, diff --git a/src/openai/resources/beta/responses/responses.py b/src/openai/resources/beta/responses/responses.py index 5bf7e019b0..c4befe5e5e 100644 --- a/src/openai/resources/beta/responses/responses.py +++ b/src/openai/resources/beta/responses/responses.py @@ -4580,8 +4580,13 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), @@ -4985,8 +4990,13 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), diff --git a/src/openai/resources/live/forks.py b/src/openai/resources/live/forks.py index ae8ed5a0cc..9b4818be8f 100644 --- a/src/openai/resources/live/forks.py +++ b/src/openai/resources/live/forks.py @@ -139,9 +139,7 @@ def __init__( max_delay=max_delay, extra_query=extra_query, extra_headers=extra_headers, - send_queue=( - send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES) - ), + send_queue=send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES), logger=log, ) self._event_handler_registry = EventHandlerRegistry(use_lock=False) @@ -458,8 +456,13 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), @@ -512,8 +515,12 @@ def _prepare_url(self) -> httpx2.URL: base_url = self.__client._base_url.copy_with(scheme=ws_scheme) path, separator, query = base_url.raw_path.partition(b"?") - endpoint = path_template("/live/sessions/{session_id}/fork", session_id=self.__session_id).encode("utf-8") - merge_raw_path = path.rstrip(b"/") + endpoint + separator + query + merge_raw_path = ( + path.rstrip(b"/") + + path_template("/live/sessions/{session_id}/fork", session_id=self.__session_id).encode("utf-8") + + separator + + query + ) return base_url.copy_with(raw_path=merge_raw_path) async def __aexit__( @@ -554,9 +561,7 @@ def __init__( max_delay=max_delay, extra_query=extra_query, extra_headers=extra_headers, - send_queue=( - send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES) - ), + send_queue=send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES), logger=log, ) self._event_handler_registry = EventHandlerRegistry(use_lock=True) @@ -869,8 +874,13 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), @@ -923,8 +933,12 @@ def _prepare_url(self) -> httpx2.URL: base_url = self.__client._base_url.copy_with(scheme=ws_scheme) path, separator, query = base_url.raw_path.partition(b"?") - endpoint = path_template("/live/sessions/{session_id}/fork", session_id=self.__session_id).encode("utf-8") - merge_raw_path = path.rstrip(b"/") + endpoint + separator + query + merge_raw_path = ( + path.rstrip(b"/") + + path_template("/live/sessions/{session_id}/fork", session_id=self.__session_id).encode("utf-8") + + separator + + query + ) return base_url.copy_with(raw_path=merge_raw_path) def __exit__( diff --git a/src/openai/resources/live/live.py b/src/openai/resources/live/live.py index 82f6f66890..abc3495e8b 100644 --- a/src/openai/resources/live/live.py +++ b/src/openai/resources/live/live.py @@ -359,9 +359,7 @@ def __init__( max_delay=max_delay, extra_query=extra_query, extra_headers=extra_headers, - send_queue=( - send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES) - ), + send_queue=send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES), logger=log, ) self._event_handler_registry = EventHandlerRegistry(use_lock=False) @@ -676,8 +674,13 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), @@ -771,9 +774,7 @@ def __init__( max_delay=max_delay, extra_query=extra_query, extra_headers=extra_headers, - send_queue=( - send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES) - ), + send_queue=send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES), logger=log, ) self._event_handler_registry = EventHandlerRegistry(use_lock=True) @@ -1083,8 +1084,13 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), diff --git a/src/openai/resources/live/sideband.py b/src/openai/resources/live/sideband.py index 84c277dfcf..a1d766337d 100644 --- a/src/openai/resources/live/sideband.py +++ b/src/openai/resources/live/sideband.py @@ -142,9 +142,7 @@ def __init__( max_delay=max_delay, extra_query=extra_query, extra_headers=extra_headers, - send_queue=( - send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES) - ), + send_queue=send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES), logger=log, ) self._event_handler_registry = EventHandlerRegistry(use_lock=False) @@ -465,8 +463,13 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, { **({"graceful_close": self.__graceful_close} if self.__graceful_close is not omit else {}), @@ -522,8 +525,12 @@ def _prepare_url(self) -> httpx2.URL: base_url = self.__client._base_url.copy_with(scheme=ws_scheme) path, separator, query = base_url.raw_path.partition(b"?") - endpoint = path_template("/live/sessions/{session_id}/attach", session_id=self.__session_id).encode("utf-8") - merge_raw_path = path.rstrip(b"/") + endpoint + separator + query + merge_raw_path = ( + path.rstrip(b"/") + + path_template("/live/sessions/{session_id}/attach", session_id=self.__session_id).encode("utf-8") + + separator + + query + ) return base_url.copy_with(raw_path=merge_raw_path) async def __aexit__( @@ -564,9 +571,7 @@ def __init__( max_delay=max_delay, extra_query=extra_query, extra_headers=extra_headers, - send_queue=( - send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES) - ), + send_queue=send_queue if send_queue is not None else SendQueue(max_bytes=DEFAULT_LIVE_SEND_QUEUE_MAX_BYTES), logger=log, ) self._event_handler_registry = EventHandlerRegistry(use_lock=True) @@ -883,8 +888,13 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, { **({"graceful_close": self.__graceful_close} if self.__graceful_close is not omit else {}), @@ -940,8 +950,12 @@ def _prepare_url(self) -> httpx2.URL: base_url = self.__client._base_url.copy_with(scheme=ws_scheme) path, separator, query = base_url.raw_path.partition(b"?") - endpoint = path_template("/live/sessions/{session_id}/attach", session_id=self.__session_id).encode("utf-8") - merge_raw_path = path.rstrip(b"/") + endpoint + separator + query + merge_raw_path = ( + path.rstrip(b"/") + + path_template("/live/sessions/{session_id}/attach", session_id=self.__session_id).encode("utf-8") + + separator + + query + ) return base_url.copy_with(raw_path=merge_raw_path) def __exit__( diff --git a/src/openai/resources/realtime/realtime.py b/src/openai/resources/realtime/realtime.py index 012f9f39c9..27fc71a5ba 100644 --- a/src/openai/resources/realtime/realtime.py +++ b/src/openai/resources/realtime/realtime.py @@ -659,8 +659,13 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async prepared_headers: Headers = extra_headers else: url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, { **({"model": self.__model} if self.__model is not omit else {}), @@ -1091,8 +1096,13 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo prepared_headers: Headers = extra_headers else: url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, { **({"model": self.__model} if self.__model is not omit else {}), diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index f3a2d7321b..71daf59964 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -4433,8 +4433,13 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), @@ -4839,8 +4844,13 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo raise OpenAIError("You need to install `openai[realtime]` to use this method") from exc url = self._prepare_url() + try: + # Older runtimes do not declare this optional helper. + query_params = getattr(self.__client, "_websocket_query_params") # noqa: B009 + except AttributeError: + query_params = self.__client._websocket_query_params_fallback url = url.copy_with( - params=self.__client._websocket_query_params( + params=query_params( url, extra_query, ), diff --git a/src/openai/resources/uploads/uploads.py b/src/openai/resources/uploads/uploads.py index 6715388d0d..6b0d038180 100644 --- a/src/openai/resources/uploads/uploads.py +++ b/src/openai/resources/uploads/uploads.py @@ -2,14 +2,10 @@ from __future__ import annotations -import io import os import logging -import builtins from typing import overload -from pathlib import Path -import anyio import httpx2 from ... import _legacy_response @@ -28,15 +24,13 @@ from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper from ..._base_client import make_request_options +from ...lib._uploads import upload_file_chunked, async_upload_file_chunked from ...types.upload import Upload -from ...types.file_purpose import FilePurpose __all__ = ["Uploads", "AsyncUploads"] -# 64MB -DEFAULT_PART_SIZE = 64 * 1024 * 1024 - +DEFAULT_PART_SIZE = 67108864 log: logging.Logger = logging.getLogger(__name__) @@ -78,7 +72,7 @@ def upload_file_chunked( part_size: int | None = None, md5: str | Omit = omit, ) -> Upload: - """Splits a file into multiple 64MB parts and uploads them sequentially.""" + "Splits a file into multiple 64MB parts and uploads them sequentially." @overload def upload_file_chunked( @@ -92,7 +86,7 @@ def upload_file_chunked( part_size: int | None = None, md5: str | Omit = omit, ) -> Upload: - """Splits an in-memory file into multiple 64MB parts and uploads them sequentially.""" + "Splits an in-memory file into multiple 64MB parts and uploads them sequentially." def upload_file_chunked( self, @@ -105,66 +99,18 @@ def upload_file_chunked( part_size: int | None = None, md5: str | Omit = omit, ) -> Upload: - """Splits the given file into multiple parts and uploads them sequentially. - - ```py - from pathlib import Path - - client.uploads.upload_file( - file=Path("my-paper.pdf"), - mime_type="pdf", - purpose="assistants", - ) - ``` - """ - if isinstance(file, builtins.bytes): - if filename is None: - raise TypeError("The `filename` argument must be given for in-memory files") - - if bytes is None: - raise TypeError("The `bytes` argument must be given for in-memory files") - else: - if not isinstance(file, Path): - file = Path(file) - - if not filename: - filename = file.name - - if bytes is None: - bytes = file.stat().st_size - - upload = self.create( - bytes=bytes, - filename=filename, + 'Splits the given file into multiple parts and uploads them sequentially.\n\n ```py\n from pathlib import Path\n\n client.uploads.upload_file(\n file=Path("my-paper.pdf"),\n mime_type="pdf",\n purpose="assistants",\n )\n ```\n' + return upload_file_chunked( + self, + file=file, mime_type=mime_type, purpose=purpose, + filename=filename, + bytes=bytes, + part_size=part_size, + md5=md5, ) - part_ids: list[str] = [] - - if part_size is None: - part_size = DEFAULT_PART_SIZE - - if isinstance(file, builtins.bytes): - buf: io.FileIO | io.BytesIO = io.BytesIO(file) - else: - buf = io.FileIO(file) - - try: - while True: - data = buf.read(part_size) - if not data: - # EOF - break - - part = self.parts.create(upload_id=upload.id, data=data) - log.info("Uploaded part %s for upload %s", part.id, upload.id) - part_ids.append(part.id) - finally: - buf.close() - - return self.complete(upload_id=upload.id, part_ids=part_ids, md5=md5) - def create( self, *, @@ -395,7 +341,7 @@ async def upload_file_chunked( part_size: int | None = None, md5: str | Omit = omit, ) -> Upload: - """Splits a file into multiple 64MB parts and uploads them sequentially.""" + "Splits a file into multiple 64MB parts and uploads them sequentially." @overload async def upload_file_chunked( @@ -409,7 +355,7 @@ async def upload_file_chunked( part_size: int | None = None, md5: str | Omit = omit, ) -> Upload: - """Splits an in-memory file into multiple 64MB parts and uploads them sequentially.""" + "Splits an in-memory file into multiple 64MB parts and uploads them sequentially." async def upload_file_chunked( self, @@ -422,77 +368,18 @@ async def upload_file_chunked( part_size: int | None = None, md5: str | Omit = omit, ) -> Upload: - """Splits the given file into multiple parts and uploads them sequentially. - - ```py - from pathlib import Path - - client.uploads.upload_file( - file=Path("my-paper.pdf"), - mime_type="pdf", - purpose="assistants", - ) - ``` - """ - if isinstance(file, builtins.bytes): - if filename is None: - raise TypeError("The `filename` argument must be given for in-memory files") - - if bytes is None: - raise TypeError("The `bytes` argument must be given for in-memory files") - else: - if not isinstance(file, anyio.Path): - file = anyio.Path(file) - - if not filename: - filename = file.name - - if bytes is None: - stat = await file.stat() - bytes = stat.st_size - - upload = await self.create( - bytes=bytes, - filename=filename, + 'Splits the given file into multiple parts and uploads them sequentially.\n\n ```py\n from pathlib import Path\n\n client.uploads.upload_file(\n file=Path("my-paper.pdf"),\n mime_type="pdf",\n purpose="assistants",\n )\n ```\n' + return await async_upload_file_chunked( + self, + file=file, mime_type=mime_type, purpose=purpose, + filename=filename, + bytes=bytes, + part_size=part_size, + md5=md5, ) - part_ids: list[str] = [] - - if part_size is None: - part_size = DEFAULT_PART_SIZE - - if isinstance(file, anyio.Path): - fd = await file.open("rb") - async with fd: - while True: - data = await fd.read(part_size) - if not data: - # EOF - break - - part = await self.parts.create(upload_id=upload.id, data=data) - log.info("Uploaded part %s for upload %s", part.id, upload.id) - part_ids.append(part.id) - else: - buf = io.BytesIO(file) - - try: - while True: - data = buf.read(part_size) - if not data: - # EOF - break - - part = await self.parts.create(upload_id=upload.id, data=data) - log.info("Uploaded part %s for upload %s", part.id, upload.id) - part_ids.append(part.id) - finally: - buf.close() - - return await self.complete(upload_id=upload.id, part_ids=part_ids, md5=md5) - async def create( self, *, diff --git a/tests/lib/test_browser_sensitive.py b/tests/lib/test_browser_sensitive.py index b1793d3aa1..97cd2e389a 100644 --- a/tests/lib/test_browser_sensitive.py +++ b/tests/lib/test_browser_sensitive.py @@ -186,9 +186,7 @@ def cancelled() -> bool: return stopped assert ( - payload(await dispatch(browser, "run_script", {**ARGS["run_script"], "source": source}, cancelled))[ - "result" - ] + payload(await dispatch(browser, "run_script", {**ARGS["run_script"], "source": source}, cancelled))["result"] == driver.result ) assert approvals[1]["arguments"]["source"] == source diff --git a/tests/lib/test_browser_zoom.py b/tests/lib/test_browser_zoom.py index 8e3c2e4675..3be20febc9 100644 --- a/tests/lib/test_browser_zoom.py +++ b/tests/lib/test_browser_zoom.py @@ -217,9 +217,7 @@ async def test_zoom_accepts_each_native_image_source( assert isinstance(content, list) and content[1] == image assert text(result) == { "ok": True, - "state": { - "tabs": [{"id": "one", "title": "Public fixture", "url": "https://fixture.example", "active": True}] - }, + "state": {"tabs": [{"id": "one", "title": "Public fixture", "url": "https://fixture.example", "active": True}]}, } assert driver.states == 2 assert driver.captures == [{"tab_id": "one", "region": [0, 0, 1, 1]}]