qwen-code/packages/sdk-python
Dinesh Palanisamy 85ae860cce
feat(core): add OpenAI Responses API content generator (#8169)
* feat(core): add OpenAI Responses API content generator

Adds an HTTP/SSE-only content generator for OpenAI's Responses API
(/v1/responses), selected via AuthType.USE_OPENAI_RESPONSES. Reasoning
continuity round-trips through part.thoughtSignature, matching the
existing Anthropic thinking-signature mechanism, so compaction and
history consolidation work unchanged.

Closes #889

* fix(cli): add openai-responses to AUTH_PREFLIGHT_ENV_KEYS

AUTH_PREFLIGHT_ENV_KEYS didn't have an entry for the new
AuthType.USE_OPENAI_RESPONSES ('openai-responses'), and it isn't in
AUTH_PREFLIGHT_WAIVED_AUTH_TYPES either, so the drift-detection test
(AUTH_PREFLIGHT_AUDITED_AUTH_TYPES covers every AuthType) failed with
expect(uncovered).toEqual([]).

OPENAI_API_KEY is the correct env var, matching the existing mapping
for this auth type in packages/cli/src/config/auth.ts:23
([AuthType.USE_OPENAI_RESPONSES]: 'OPENAI_API_KEY') and
packages/core/src/models/constants.ts's AUTH_ENV_MAPPINGS.

Verification: authPreflight.test.ts 3/3 passing (was failing before
this fix), tsc --noEmit clean, eslint clean.

* fix(openai-responses): address review findings on the streaming pipeline and converter

Addresses the automated /review findings (doudouOUC, qwen-code-ci-bot) left
on #8169 after the AUTH_PREFLIGHT_ENV_KEYS fix.

Critical fixes in responses-pipeline.ts's streamRequest:
- baseUrl fallback used `??`, which does not catch an empty string --
  USE_OPENAI_RESPONSES has no case in ModelRegistry.getDefaultBaseUrl, so an
  unconfigured baseUrl resolves to '' and every request hit the relative
  path /v1/responses. Switched to `||`.
- streamRequest called the global `fetch` instead of the `fetch`
  buildRuntimeFetchOptions pins alongside its dispatcher. Node's built-in
  undici can be a different major version than the bundled one, and handing
  it a foreign dispatcher throws `invalid onError method` -- this broke
  every streaming request whenever a dispatcher was installed (which is the
  common case, proxy or not).
- Network errors from fetch() and non-ok HTTP responses were thrown
  unredacted, leaking proxy credentials into error messages/logs.
- No Content-Type validation: an HTTP 200 non-SSE body (a gateway/proxy
  block page, or a baseUrl pointed at a non-API endpoint) silently yielded
  zero events instead of a surfaced, retryable failure.
- The SSE parser only flushed a pending event on a blank line and never
  after the read loop ended, so a stream that closes without a trailing
  blank line (a proxy stripping trailing whitespace, or an interrupted
  connection) silently dropped its last event -- including the terminal
  response.completed event carrying usageMetadata/finishReason.

Critical fixes in index.ts's embedContent:
- Missing redactProxyError on the embedding error path (present on the
  sibling openaiContentGenerator and on anthropicContentGenerator).
- The embed client passed baseUrl to the OpenAI SDK unnormalized; this
  generator's own /v1-less baseUrl convention (the streaming pipeline
  strips/re-appends /v1 itself) 404s every embedding call against the SDK,
  which does not auto-append /v1 to a custom baseURL. Normalized the same
  way the streaming pipeline does.

Suggestions fixed in responses-converter.ts:
- `buf?.args ?? fc.arguments`: initFunctionCall seeds buf.args to '', so a
  buffer that received output_item.added but no
  function_call_arguments.delta events never fell through to the done
  item's own complete arguments. Switched to `||`.
- A user turn with text + image parts was split into two separate
  'message' items; the Responses API treats them as two distinct turns, so
  the model could fail to associate a question with its image. Parts now
  accumulate per-turn and flush as one message with a multi-part content
  array, in original relative order around any function_call/
  function_call_output/reasoning items.
- Non-image inlineData and all fileData references were silently dropped
  with no diagnostic. They now become a text placeholder noting the
  unsupported attachment (no input_file part type is wired up for this
  generator yet) instead of vanishing.
- Tool results were sent as the raw { output } / { error } JSON envelope
  instead of the unwrapped string the sibling Chat Completions converter
  sends, double-encoding and quote-escaping any tool output that is itself
  JSON.
- Removed ResponsesStreamState.reset(), an unused method contradicting the
  documented one-instance-per-stream contract.

Other:
- Extracted the embedContent text-extraction logic (byte-identical between
  this generator and the sibling openaiContentGenerator) into a shared
  extract-text-from-contents.ts helper.
- Added test coverage: multi-part message merging, attachment placeholders,
  tool-result envelope unwrapping, the buf.args empty-buffer fallback,
  sanitizePromptCacheKey's truncation/hash path, the data-only SSE framing
  branch (the shape the Responses API actually emits, previously untested),
  and the USE_OPENAI_RESPONSES factory route in createContentGenerator.
- responses-pipeline.test.ts previously stubbed the global fetch, which the
  dispatcher/fetch fix above now bypasses (the pipeline calls the pinned
  fetch instead); switched to mocking buildRuntimeFetchOptions directly so
  the mock intercepts the fetch the pipeline actually uses.

* fix(openai-responses): close remaining review gaps from PR #8169

Re-audited every open review thread on PR #8169 against the current
code (commit 09ff5d996). Most had already been fixed in that commit;
this addresses the six that were still open, all Suggestions (no
Criticals remained).

Two real behavior fixes:
- responses-pipeline.ts: a stream that closes with a final `data: `
  line carrying no trailing newline at all was silently dropped.
  `buffer.split('\n')` never routes an unterminated line through the
  main per-line loop, and the existing post-loop flush only handled a
  missing trailing *blank line* (an already-parsed event awaiting its
  terminator), not an entirely unterminated line still sitting in
  `buffer`. Added a targeted post-loop check for this case, with a
  regression test confirmed failing before the fix (0 chunks yielded)
  and passing after.
- index.ts: `getOpenAIClient()` never passed `customHeaders` to the
  OpenAI SDK client, while the streaming pipeline
  (responses-pipeline.ts) already applies them to every request --
  headers configured for the streaming path (e.g. a proxy auth header)
  silently never reached `embedContent`. Added `defaultHeaders`,
  mirroring the pipeline. Regression test confirmed failing before the
  fix and passing after.

Four test-coverage gaps closed:
- contentGenerator.test.ts: the USE_OPENAI_RESPONSES routing test
  only asserted `totalTokens > 0`, which a mocked Chat generator
  would also satisfy. Added a dedicated mock for
  createOpenAIResponsesContentGenerator with a distinguishable
  sentinel and asserted the Chat generator's mock was never invoked.
- index.test.ts: `embedContent`'s `model.includes('embed') ? model :
  'text-embedding-ada-002'` branch was never asserted on either side;
  added the missing `model` assertion to an existing case plus a new
  case for the `includes('embed')` branch.
- index.test.ts: `generateContent`/`generateContentStream`'s
  `request.config?.abortSignal ?? undefined` forwarding was only ever
  exercised with `undefined`. Added cases passing a real
  `AbortController` signal through both methods.
- responses-converter.test.ts: `normalizeResponsesParameters`'s wiring
  into `convertGeminiToolsToResponsesTools` was untested end-to-end --
  every existing tool-conversion case already had `properties`, making
  normalization a no-op. Added a zero-arg-tool case that actually
  exercises it.

Verified: 100 tests passing across the four touched suites (39 + 21 +
18 + 22), each new/changed assertion confirmed to fail on the
pre-fix code where applicable. Typecheck and lint clean.

* fix(core): address review round on the Responses wire (7 criticals + coverage)

Addresses the review round on #8169. Every finding was re-verified against
the code before acting; all were confirmed. Each behavior fix has a
regression test that was checked red-before / green-after in place.

Retry, timeout and error classification:
- generateContentStream returned a lazy async generator, so its promise
  resolved before any network I/O and connection-time 5xx/429 could never
  reach retryWithBackoff. Split the pipeline into an eager connect phase
  (fetch + non-2xx handling + content-type guard) and a lazy body-iteration
  phase behind connectStream(), and wired index.ts to it. executeStream is
  retained and now delegates, so existing callers are unaffected.
- The stream read loop had no timeout layer at all and the undici dispatcher
  uses headersTimeout:0/bodyTimeout:0, so a 200-then-silent upstream hung
  the turn forever with no user present to cancel. Added an idle watchdog
  honoring streamIdleTimeoutMs / QWEN_STREAM_IDLE_TIMEOUT_MS, reusing the
  Chat pipeline's shared constants and its retryable ETIMEDOUT semantics.
  (Note: the reviewer's cited helper `withStreamInactivityTimeout` does not
  exist; the real sibling is `withStreamGuards`, whose structure is mirrored
  here rather than imported, since importing it pulls in the whole OpenAI
  SDK graph.)
- Mid-stream `error` / `response.failed` frames arrive after a 200 OK and
  were thrown as plain Errors with no `.status` and no `.code`, so they
  classified as `unknown` and missed the rate-limit retry, transport replay,
  model fallback and persistent-mode wait. Map the known codes onto a status
  before throwing.
- getOpenAIClient passed raw timeout/maxRetries to the SDK, bypassing
  resolveRequestTimeout (so `timeout: 0`, a documented "disable" knob, meant
  immediate abort) and DEFAULT_MAX_RETRIES.

Tool-call argument handling:
- A corrupt non-empty delta buffer shadowed the done item's authoritative
  `arguments`, dispatching tool calls with `{}`. Fall back to the done
  item's string on parse failure.
- Malformed-but-repairable arguments bypassed the safeJsonParse/jsonrepair
  fallback every sibling wire applies to the same input class.
- Arguments parsing to an array/null/primitive flowed through the
  `as Record` cast into functionCall.args; collapse non-objects to `{}` as
  streamingToolCallParser does.

Fidelity and observability:
- max_output_tokens: buildRequest never read request.config.maxOutputTokens,
  dropping the per-send window clamp geminiChat computes (#5950).
- functionResponse.parts media was silently discarded; tool-returned images
  now reach the wire as a follow-up input_image message.
- The converter never called setGenAiUsageProvenance while always
  materializing cachedContentTokenCount, so every turn reported cache
  statistics as provider-reported.

Reachability and diagnostics:
- AuthType.USE_OPENAI_RESPONSES was missing from the --auth-type yargs
  choices, so `qwen --auth-type=openai-responses` was rejected outright.
- The new PROTOCOL_ITEMS entry was unreachable UI. Added the auth type to
  customProvider.protocolOptions; verified the type is wired end to end
  (contentGenerator creation, AUTH_PREFLIGHT_ENV_KEYS, validateAuthMethod,
  DEFAULT_BASE_URLS) so the wizard option resolves to a working generator.
- systemInfo.ts and the RUM base_url gate were not extended for the new auth
  type, degrading /bug and /about output and losing endpoint attribution.
- De-duplicated the part->text lambda in extract-text-from-contents.

Coverage: filled the 29 flagged untested-behavior sites (multi-line data:
frames, UTF-8 split across chunk boundaries, extra_body precedence with an
explicit 0, custom header forwarding, abort-signal identity and mid-stream
abort, dispatcher forwarding, proxy-credential redaction, the non-SSE
content-type guard, malformed frames, empty-merge, and the auth/telemetry
entries).

Verification: 111/111 in openaiResponsesContentGenerator, 54/54 in the other
touched core suites, 402/404 in the touched CLI suites (the 2 failures are
pre-existing and reproduce on a pristine checkout -- config.test.ts reads
the ambient OTEL_EXPORTER_OTLP_ENDPOINT). tsc clean, eslint clean, prettier
clean.

* refactor(core): actually de-duplicate the part->text lambda in extractTextFromContents

Correction to dcd8a67d9, whose commit message claimed this was done. It was
not -- the subagent that owned the file returned a WONTFIX for it and the
claim went into the message unverified. The lambda was still duplicated
verbatim between the array-input and single-content branches of the very
helper whose JSDoc promises "a change to how Part text is shaped only needs
to be made once".

Extracted `textOfParts` and routed both branches through it. Behavior is
unchanged for every input shape.

Added the collocated test file the helper never had, including a guard that
converts the same Content bare and wrapped in a single-element array and
asserts the two agree -- the drift this extraction exists to prevent, and
which the pre-extraction code had already let happen once. Verified by
re-injecting a divergent copy into the array branch: that test alone goes
red, the other five stay green.

117/117 across the Responses directory plus the new file; tsc, eslint and
prettier clean.

* fix(core): address round-2 review on the Responses wire

The first automatic review to complete on this PR (previous three attempts
timed out). 30 inline findings plus five Criticals that were recorded in the
review ledger but never posted inline. Every finding was verified against
the code before acting.

Pipeline:
- max_output_tokens precedence reopened the user's ceiling. Last round used
  `request.config?.maxOutputTokens ?? samplingParams.max_tokens`, which
  returns the larger per-send value for config 1000 + per-send 8000. Now
  routes through tokenLimits' `reconcileMaxTokens` "smaller wins" invariant,
  which both sibling wires call and which exists precisely so a new provider
  can't silently reopen that ceiling. The reviewer was right about our own
  previous fix.
- `tool_choice: 'auto'` and `parallel_tool_calls: true` were sent
  unconditionally. Toolless requests are reachable here (compaction via
  baseLlmClient.generateText, text-mode side queries) and strict endpoints
  400 on tool_choice with no tools, which can wedge compaction in a retry
  loop. Gated on tools being present, matching the sibling.
- The connect phase had no timeout at all, and `ContentGeneratorConfig.timeout`
  was never read by this pipeline. An endpoint that completes TCP/TLS but
  never sends headers blocked the turn forever: the pinned undici Agent runs
  headersTimeout:0/bodyTimeout:0, the idle watchdog only wraps reader.read()
  inside iterateBody, and retryWithBackoff has no per-attempt timeout.
  Added StreamConnectTimeoutError via the same resolveRequestTimeout helper
  the embed client uses.
- Only the idle watchdog existed, so an upstream drip-feeding one chunk per
  window re-armed it forever. Added StreamLifetimeExceededError using the
  sibling's shared DEFAULT_STREAM_MAX_LIFETIME_MS /
  QWEN_STREAM_MAX_LIFETIME_MS_ENV constants, and folded the idle/lifetime
  resolvers into one helper that now warns on invalid values instead of
  silently discarding them.

Converter:
- A delta buffer that parsed cleanly to a non-object (array/null/primitive)
  bypassed the done-item authoritative-arguments fallback, which only ran in
  the catch branch, so a tool call dispatched with `{}` while valid
  `arguments` sat in the same event. Residual of last round's fix, which
  covered only the parse-throwing case.
- Refusals were silently dropped: no `response.refusal.delta` / `.done`
  handling, so a refusal produced zero content chunks, threw
  NO_RESPONSE_TEXT, and retried four times re-sending the full prompt before
  surfacing a misleading error. Added both events and the refusal content
  part; deltas emit as text, mirroring output_text.delta.
- Tool-parameter precedence was inverted relative to both siblings
  (`parameters ?? parametersJsonSchema`); swapped to match.

Generator, CLI and SDKs:
- LazyContentGenerator.useSummarizedThinking() omitted the new auth type.
- The custom-provider test mixed barrel imports (resolving to dist/) with
  source imports, so the per-file vitest flow saw an undefined
  AuthType.USE_OPENAI_RESPONSES against a stale dist. Both sides now come
  from source.
- Both bundled SDKs enumerate every CLI auth type but omitted this one, so
  SDK users could not select it: added to the TypeScript zod enum and union
  and to the Python Literal and validation set, with a parametrized test
  pinning the whole set against the CLI's --auth-type choices.

Coverage: filled the flagged gaps across all three files — per-output_index
function-call isolation, cached_tokens:0 absent-vs-zero, the reasoning
shape-guard, functionResponse text-append, string systemInstruction,
multi-Tool conversion, non-mutation recursion, proxy hand-off, apiKeyEnvKey
fallback, Content-Type, error-body excerpt, .status stamping, ETIMEDOUT
code, per-chunk timer reset, the no-body guard, empty-string baseUrl, the
countTokens fallback arithmetic, maxRetries pass-through, the no-baseUrl
embed branch, trailing-slash normalization, and abort-signal null handling.

Every behavior change is mutation-verified: fix reverted, test observed red,
fix restored.

Not changed, flagged for maintainers:
- `prompt_cache_key` is keyed on `userPromptId` (`${sessionId}####${counter}`),
  which changes every turn and so defeats cross-turn prefix-cache affinity.
  Keying on the stable session prefix is a behavior change, so it is raised
  rather than made unilaterally.
- ACP `buildAuthMethods()` advertises only USE_OPENAI — it does not advertise
  Anthropic, Gemini or Qwen OAuth either, so this is pre-existing scope
  rather than a gap introduced here.

One ledger Critical (the pinned `fetch` being discarded in favour of Node's
global) is INVALID as written: it describes pre-round-1 code. `fetchFn =
runtimeOptions?.fetch ?? fetch` landed in 09ff5d996, and the pipeline tests
deliver their mock through `runtimeOptions.fetch` without stubbing the
global, so a regression to global fetch would fail the suite.

Verification: 174 core tests across the six touched core suites, 419 CLI
tests across seven, 119 Python SDK unit tests; tsc clean for core, cli and
sdk-typescript; eslint, prettier and ruff clean.

* fix(core): key the Responses prefix cache on the session, not the turn

`prompt_cache_key` was set to `sanitizePromptCacheKey(userPromptId)`, and
userPromptId is `${sessionId}########${counter}` -- it changes on every send.
Each turn therefore got its own cache namespace and no request in a session
could ever hit the prefix cache the previous one wrote. Nothing fails when
that happens; the entire cost is silent latency and spend, which is why it
survived until review flagged it.

Keyed on `qwen-code:${sessionId}` instead, matching the Chat wire's
`applyOfficialOpenAIPromptCaching` (openaiContentGenerator/prefix-caching.ts).

Note on provenance, since it changes how this reads: that sibling behavior
landed on main in 6897ef744 (#8418) on 2026-08-07, which is AFTER this branch
last merged main (770146526) and is not in this branch's history. So this was
not a case of ignoring an existing helper -- main converged on session-scoped
keying independently while this PR was in review, and the two wires would
otherwise have disagreed on merge.

The `qwen-code:` prefix is written as a literal here because
prefix-caching.ts on this branch's base predates the constant. Once main is
merged in, this should import the shared CACHE_KEY_PREFIX rather than keep a
second copy of the string.

`Config.getSessionId()` is typed `string` and never returns undefined, so the
guard covers the reachable empty-string case only; the mock and the fallback
test were written against that rather than a `undefined` state the real API
cannot produce.

Test pins the actual property -- two different userPromptIds in one session
produce the same key. Reverting to per-turn keying fails it. 135 tests pass
across the directory; tsc, eslint and prettier clean.

* fix(core): route the setup wizard's enable_thinking through reasoning.effort on the Responses wire

wenshao's local verification report on #8169 found that picking
`openai-responses` in the setup wizard and enabling "Enable thinking"
produced a session with zero reasoning, because the wizard writes the
DashScope-specific `extra_body.enable_thinking` toggle for every
protocol. The Responses wire has no DashScope-detection layer (unlike
the OpenAI Chat pipeline), so it forwarded `enable_thinking` onto the
wire verbatim as an undefined field instead of requesting reasoning at
all.

- provider-config.ts: buildAdvancedGenerationConfig now takes the
  resolved protocol and writes `reasoning: { effort: 'medium' }`
  instead of `extra_body.enable_thinking` when the protocol is
  `openai-responses`.
- responses-pipeline.ts: defense in depth for configs that still carry
  a legacy `extra_body.enable_thinking` (hand-edited settings, ACP,
  etc.) — buildReasoning() treats it as `effort: 'medium'` when no
  explicit `reasoning` is set, and the extra_body merge always strips
  the key so it can never reach the wire literally.
- docs: add `openai-responses` to the supported-auth-types and SDK
  tables, a full config example, a row in the reasoning per-provider
  table, and correct settings.md's `extra_body` note (it previously
  said the field was `openai`/`qwen-oauth` only).

Addresses PR #8169 review findings 3.1 and 3.2.

* fix(core): reject unsupported Responses image MIME types

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(responses): recover rejected reasoning replay once

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(responses): reject malformed replay errors

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(responses): constrain proxy error recovery

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(auth): expose OpenAI Responses consistently

* fix(responses): bound error response handling

* fix(responses): honor request-scoped controls

* fix(auth): align Responses setup preview

* fix(responses): preserve request thinking opt-out

* fix(responses): require SSE content type

* test(cli): include Responses auth in top-level help expectation

* fix(responses): address protocol compatibility review

* fix(responses): redact diagnostics and normalize custom headers

---------

Co-authored-by: Palanisamy, Dinesh <Dinesh.Palanisamy@netapp.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Dragon <52599892+DragonnZhang@users.noreply.github.com>
Co-authored-by: probe <probe@local>
2026-09-09 22:36:23 +00:00
..
scripts feat: add commit attribution with per-file AI contribution tracking (#3115) 2026-05-08 09:55:58 +08:00
src/qwen_code_sdk feat(core): add OpenAI Responses API content generator (#8169) 2026-09-09 22:36:23 +00:00
tests feat(core): add OpenAI Responses API content generator (#8169) 2026-09-09 22:36:23 +00:00
pyproject.toml feat(SDK) Add Python SDK implementation for #3010 (#3494) 2026-04-25 07:02:58 +08:00
README.md fix(sdk): support "auto" permission mode (#9003) 2026-08-22 14:25:40 +00:00

qwen-code-sdk

Experimental Python SDK for programmatic access to Qwen Code through the stream-json protocol.

Installation

pip install qwen-code-sdk

For preview releases, enable pre-release resolution:

pip install --pre qwen-code-sdk

Requirements

  • Python >=3.10
  • External qwen CLI installed and available in PATH

You can also point the SDK at an explicit CLI binary or script with path_to_qwen_executable.

Before using the SDK, verify that the CLI works in the same environment:

qwen --version

Quick Start

import asyncio

from qwen_code_sdk import (
    is_sdk_assistant_message,
    is_sdk_result_message,
    query,
)


def text_from_message(message):
    content = message.get("message", {}).get("content", [])
    if not isinstance(content, list):
        return repr(content)
    texts = [
        block.get("text", "")
        for block in content
        if isinstance(block, dict) and block.get("type") == "text"
    ]
    return "".join(texts) if texts else "[no text content]"


def print_result(message):
    if message.get("is_error"):
        error = message.get("error") or {}
        print(f"Error: {error.get('message', 'Unknown error')}")
        return
    print(message.get("result", ""))


async def main() -> None:
    async with query(
        "List the top-level packages in this repository.",
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
        },
    ) as result:
        async for message in result:
            if is_sdk_assistant_message(message):
                print(text_from_message(message))
            elif is_sdk_result_message(message):
                print_result(message)


asyncio.run(main())

asyncio.run() is appropriate for standalone scripts. If your application already runs an event loop, such as Jupyter, FastAPI, or pytest-asyncio, call await main() instead.

Sync API

from qwen_code_sdk import is_sdk_result_message, query_sync


with query_sync(
    "Say hello",
    {
        "cwd": "/path/to/project",
        "path_to_qwen_executable": "qwen",
    },
) as result:
    for message in result:
        if is_sdk_result_message(message):
            if message.get("is_error"):
                error = message.get("error") or {}
                print(f"Error: {error.get('message', 'Unknown error')}")
            else:
                print(message.get("result", ""))

Main APIs

  • query(prompt, options=None) -> Query
  • query_sync(prompt, options=None) -> SyncQuery
  • Query.close(), interrupt(), set_model(), set_permission_mode()
  • Query.supported_commands(), mcp_server_status(), get_session_id()

prompt accepts either a single str or an AsyncIterable[SDKUserMessage] for multi-turn sessions.

Common Options

options = {
    "cwd": "/path/to/project",
    "path_to_qwen_executable": "qwen",
    "model": "qwen-plus",
    "permission_mode": "plan",
    "max_session_turns": 1,
    "env": {
        "OPENAI_MODEL": "qwen-plus",
    },
    "timeout": {
        "control_request": 60,
        "can_use_tool": 60,
        "stream_close": 60,
    },
}

Common fields:

  • cwd: working directory used by the CLI
  • path_to_qwen_executable: qwen, an absolute binary path, or a .js CLI bundle
  • model: model override for this session
  • permission_mode: one of default, plan, auto-edit, auto, or yolo; auto lets an LLM classifier approve tool calls; yolo auto-approves all tools, so use it only in trusted or sandboxed environments
  • env: extra environment variables passed to the CLI process
  • system_prompt / append_system_prompt: override or extend the system prompt
  • core_tools, exclude_tools, allowed_tools: constrain tool availability
  • timeout: seconds for control requests, permission callbacks, and stream close waits

env is merged on top of the parent process environment. Set secrets such as OPENAI_API_KEY in the parent environment or a secrets manager rather than hardcoding them in source.

Multi-Turn Sessions

For multi-turn use cases, pass an async iterable of SDKUserMessage objects. Use a stable UUID for session_id when you want to correlate messages:

import asyncio

from qwen_code_sdk import SDKUserMessage, is_sdk_result_message, query

SESSION_ID = "123e4567-e89b-12d3-a456-426614174000"


async def prompts():
    first: SDKUserMessage = {
        "type": "user",
        "session_id": SESSION_ID,
        "message": {"role": "user", "content": "Create a short project summary."},
        "parent_tool_use_id": None,
    }
    yield first

    second: SDKUserMessage = {
        "type": "user",
        "session_id": SESSION_ID,
        "message": {"role": "user", "content": "Also list the test files."},
        "parent_tool_use_id": None,
    }
    yield second


async def main():
    async with query(
        prompts(),
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
            "session_id": SESSION_ID,
        },
    ) as result:
        async for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

All messages in the async iterable must be known upfront. The SDK sends them sequentially to the CLI but cannot feed a prior response back into the generator. If you need conversational turn-taking, manage each turn as a separate query() call.

Permission Callback

import asyncio
from pathlib import Path

from qwen_code_sdk import is_sdk_result_message, query

PROJECT_ROOT = Path("/path/to/project").resolve()


def project_path(tool_name, tool_input):
    key = "path" if tool_name == "list_directory" else "file_path"
    raw_path = tool_input.get(key)
    if not isinstance(raw_path, str) or not raw_path:
        return None

    resolved = (PROJECT_ROOT / raw_path).resolve()
    try:
        resolved.relative_to(PROJECT_ROOT)
    except ValueError:
        return None
    return resolved


async def can_use_tool(tool_name, tool_input, context):
    if tool_name in {"read_file", "list_directory", "write_file"}:
        resolved = project_path(tool_name, tool_input)
        if resolved is None:
            return {
                "behavior": "deny",
                "message": "Only project-local paths are allowed",
            }

        if tool_name == "write_file" and resolved.suffix != ".md":
            return {"behavior": "deny", "message": "Only .md files can be written"}

        return {"behavior": "allow", "updatedInput": tool_input}

    return {
        "behavior": "deny",
        "message": f"{tool_name} is not allowed by this application",
    }


async def main():
    async with query(
        "Update README.md with a one paragraph summary.",
        {
            "cwd": str(PROJECT_ROOT),
            "path_to_qwen_executable": "qwen",
            "can_use_tool": can_use_tool,
        },
    ) as result:
        async for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

The callback defaults to deny. If it does not return within timeout.can_use_tool seconds, the SDK auto-denies the tool request. The default timeout is 60 seconds.

The context argument includes cancel_event, suggestions, and blocked_path when the CLI provides a path-specific permission target. can_use_tool must be an async def callback accepting (tool_name, tool_input, context). stderr must accept a single str.

Handling ask_user_question

When the model needs a decision from the user it calls the built-in ask_user_question tool. This flows through the same can_use_tool callback: tool_input carries a questions list, and you return the collected answers via updatedInput["answers"]. answers is a dict keyed by the question's index (as a string), where each value is the label of the chosen option (or free-form text when the user picks "Other").

async def can_use_tool(tool_name, tool_input, context):
    if tool_name == "ask_user_question":
        questions = tool_input["questions"]

        # Present the questions to the user however your app sees fit,
        # then build an index-keyed map of their answers.
        answers = {}
        for index, question in enumerate(questions):
            answers[str(index)] = await prompt_user_to_choose(question)

        # Return the answers through updatedInput["answers"] — the CLI
        # forwards them to the tool so the model receives the decisions.
        return {
            "behavior": "allow",
            "updatedInput": {**tool_input, "answers": answers},
        }

    return {"behavior": "allow", "updatedInput": tool_input}

If you return allow without any answers, the tool reports that no answer was provided; return deny to signal the user declined.

Runtime Controls

Control methods can be called while a session is active:

import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main():
    async with query(
        "Inspect this project and wait for my next instruction.",
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
        },
    ) as result:
        commands = await result.supported_commands()
        print(commands)

        await result.set_permission_mode("plan")
        await result.set_model("qwen-plus")

        async for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

Use interrupt() to cancel the current CLI operation and close() to clean up the underlying process.

Resuming Sessions

import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main():
    # Resume a known session.
    async with query(
        "Continue from the previous state.",
        {
            "path_to_qwen_executable": "qwen",
            "resume": "123e4567-e89b-12d3-a456-426614174000",
        },
    ) as result:
        async for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

To continue the latest session instead:

import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main():
    async with query(
        "Continue the last session.",
        {
            "path_to_qwen_executable": "qwen",
            "continue_session": True,
        },
    ) as latest:
        async for message in latest:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

Use only one of resume, continue_session, or session_id in a request. The SDK raises ValidationError if these session options are combined.

Error Handling

  • ValidationError: invalid query options or malformed session identifiers
  • ControlRequestTimeoutError: CLI control operation exceeded timeout
  • ProcessExitError: qwen exited with a non-zero code
  • AbortError: query or control request was cancelled
from qwen_code_sdk import (
    ProcessExitError,
    ValidationError,
    is_sdk_result_message,
    query_sync,
)

try:
    with query_sync("Say hello", {"path_to_qwen_executable": "qwen"}) as result:
        for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))
except ValidationError as exc:
    print(f"Invalid SDK options: {exc}")
except ProcessExitError as exc:
    print(f"qwen exited with {exc.exit_code}: {exc}")

Current Scope

0.1.x is intentionally narrow:

  • Uses external qwen CLI via process transport
  • Targets stream-json parity with the TypeScript SDK core flow
  • Does not yet implement ACP transport
  • Does not yet embed MCP servers inside the SDK process

See developer documentation for more detail.