V3 issue #228: structured provider response contract
Issue: https://github.com/EvanProgramming/OpenKyrozen/issues/228 (R001, R002, R004, R007) Related: https://github.com/EvanProgramming/OpenKyrozen/issues/225 Base: current main 25588796b4ed187dbde84b763042512c0e598405 Branch: Evan/v3-228-provider-contracts
Task 1 — SDK-independent contract (RED → GREEN)
Define ToolCall (nonempty id/name, JSON object arguments), ModelResponse (text, ordered calls, usage, finish reason, explicit metadata), ProviderCapabilities and FinishReason in providers/models.py. Normalize final/tool/length/error/cancelled/ blocked/unknown outcomes. Never infer final from text or an absent finish status. Preserve native IDs; synthesize response-scoped IDs only for transports omitting IDs and identify them in metadata. Reject malformed/non-object/non-JSON arguments. Tests must first fail for lost calls and finish reasons before adding code.
Task 2 — adapter and compatibility migration
Add LLMProvider.chat_response() and get_capabilities(); keep chat() and text streaming. Base chat_response bridges legacy-only providers with UNKNOWN status. Native adapters expose full non-stream responses and chat delegates through the shared checked compatibility conversion. That conversion refuses calls, tool requests, length stops, errors, cancellation and blocked output. Preserve ordinary text behavior and existing accounting exactly once, even for rejected responses.
Implement each current transport: OpenAI chat/Azure, OpenAI Responses, Anthropic, Google/Vertex, Bedrock, Ollama, Perplexity. Do not add tool request serialization. Capabilities represent implemented adapter request features, not guessed endpoint or model capabilities: native tools, strict schemas, parallel/streaming tools and reasoning controls remain false until those paths exist; text streaming reflects the actual override. Fallback uses model-mapped conservative intersections and preserves the responding provider metadata; conversion failure is not a reason to replay a successful structured response through another provider.
Task 3 — shared runtime boundary
Add _get_model_response() to the existing providers/calls boundary and bind it on AgentRuntime. Reuse bounded calls, scoped usage, context and token accounting. _get_llm_response retains its string API through checked conversion. Legacy duck providers remain supported, without treating dynamically invented mock attributes as an explicit structured API. Current stream handling remains text-only; native streaming is explicitly unsupported here and owned by #230. Do not invoke tools.
Task 4 — verification and delivery
Cover all transports with text-only, mixed/tool-only, multiple-call and malformed argument fixtures; validate IDs, usage, finish status, metadata and inheritance. Use real installed SDK response types where available to check fixture shape. Test legacy callers/providers, fallback identity/model mapping/capabilities, lossy conversions, runtime structured receipt, no tool execution and cost-once. Run focused tests on supported Python 3.12/3.13, full make test, make check, lint, docs-check and offline agent/subagent acceptance. Obtain fresh independent review and fix material findings. Sign/verify commits with GPG, create/attach one PR, resolve valid review feedback and wait for final-head CI. Do not merge.
Limits
No paid provider calls, new dependencies, raw SDK objects/credentials in metadata, or changes to native request serialization (#229), registry (#232), structured streaming (#230), or AgentEngine execution (#238). Live interoperability remains unverified. Record this limitation in the PR rather than claiming fixture tests are live acceptance.