핵심 요약
- Provider API boundary는 단순 SDK wrapper가 아니다.
- 내부 message, tool schema, system context, model options를 provider payload로 번역한다.
- streaming response는 내부 runtime event로 변환되어야 한다.
- provider SDK 타입이 session shell, tool runtime, ledger 전체로 새면 유지보수가 어려워진다.
이번 글에서는 model provider API boundary를 보겠습니다.
agent runtime은 당연히 모델 API에 의존합니다. 하지만 제품 전체가 provider SDK의 타입과 event 이름에 끌려가면 안 됩니다. provider를 바꾸거나 fallback을 추가하거나 streaming 처리 방식을 바꿀 때 앱 전체가 흔들리기 때문입니다.
Claude Code류의 agent를 runtime 관점으로 보면 모델 호출 경계는 단순 wrapper가 아닙니다. 내부 message와 provider payload 사이를 번역하고, tool schema를 고르고, streaming response를 runtime event로 바꾸고, usage와 cost를 추적하는 계층입니다.
1. Provider SDK 타입이 앱 전체로 새면 생기는 문제
처음에는 provider SDK 타입을 그대로 쓰는 것이 편합니다. 하지만 agent가 커지면 문제가 됩니다.
- session shell이 provider event 이름을 알아야 한다.
- ledger가 provider usage 형식을 직접 읽어야 한다.
- tool runtime이 provider tool schema 형식에 묶인다.
- fallback model을 추가하려면 모든 계층을 수정해야 한다.
- 테스트에서 provider stream을 mocking하기 어렵다.
따라서 내부 runtime은 자체 event vocabulary를 가져야 합니다.
# 읽는 법: 아래 항목은 동작 흐름을 빠르게 확인하기 위한 요약 예시입니다.provider stream event→ provider adapter→ runtime event→ shell / ledger / accounting / tool loop
2. API boundary의 책임
| 책임 | 설명 |
|---|---|
| message encoding | 내부 message를 provider payload로 변환 |
| system context assembly | system prompt, policy hint, workspace context 조립 |
| tool schema selection | 현재 turn에서 노출할 도구 schema 선택 |
| option mapping | model, token limit, output format, budget hint 변환 |
| stream translation | provider event를 runtime event로 변환 |
| usage accounting | token/cache/cost 정보를 내부 형식으로 누적 |
| error handling | retry, fallback, non-streaming path 추상화 |
이 계층이 있으면 model provider가 바뀌어도 agent runtime의 다른 계층은 그대로 유지될 가능성이 높습니다.
3. Message encoding과 tool schema selection
모든 도구 schema를 항상 모델에게 보내는 것은 비용 면에서 좋지 않습니다. 반대로 필요한 도구가 누락되면 모델이 적절한 요청을 만들 수 없습니다.
따라서 API boundary는 현재 turn에 필요한 도구를 고르는 정책을 가져야 합니다.
| 도구 종류 | 노출 전략 |
|---|---|
| 기본 read-only 도구 | 대부분의 turn에서 노출 가능 |
| write 도구 | 권한 모드와 사용자 요청에 따라 제한 |
| 고비용 외부 도구 | 명시적 요청 또는 command에서만 노출 |
| plugin 도구 | availability와 policy 통과 후 노출 |
tool schema selection은 tool runtime과 연결되지만, provider payload에 넣는 최종 조립은 API boundary에서 하는 편이 깔끔합니다.
4. Stream event translation
provider마다 stream event 구조가 다를 수 있습니다. 내부 runtime은 다음 정도의 공통 event만 알면 됩니다.
| 내부 event | 의미 |
|---|---|
assistant_started | assistant response 시작 |
assistant_text_delta | 부분 텍스트 |
tool_request_ready | 실행 가능한 tool request 생성 |
usage_updated | 사용량 갱신 |
assistant_finished | assistant response 종료 |
provider_error | provider 오류 |
이렇게 하면 session shell은 “부분 텍스트를 표시한다”만 알면 되고, provider가 어떤 raw event를 줬는지는 몰라도 됩니다.
5. Usage와 cost accounting
streaming API에서는 usage 정보가 마지막에만 오지 않을 수 있습니다. 중간 event로 갱신될 수도 있고, provider마다 cache token이나 reasoning token 같은 세부 항목을 다르게 표현할 수 있습니다.
따라서 usage는 final response에서 한 번 읽는 것이 아니라 stream 중간에 계속 흡수하는 구조가 좋습니다.
주의:
장기 실행 agent에서 비용 추적을 마지막에만 계산하면 중간 취소, retry, fallback, compaction 상황에서 오차가 커질 수 있습니다. budget enforcement가 필요하다면 stream 중간 usage update를 처리해야 합니다.
6. 개념 코드로 보는 provider adapter
아래 코드는 설명용으로 새로 작성한 코드입니다.
# 읽는 법: 실제 구현 복제가 아니라 runtime 경계를 설명하는 개념 코드입니다.class ProviderAdapter: # 객체가 이후 단계에서 참조할 runtime 의존성과 상태 저장소를 초기화합니다. def __init__(self, client, encoder, pricing): self.client = client self.encoder = encoder self.pricing = pricing # runtime message와 tool context를 provider가 이해하는 요청 payload로 인코딩합니다. def build_request(self, messages, tool_context): return { "messages": self.encoder.encode_messages(messages), "tools": self.encoder.encode_tools(select_tools(tool_context)), "system": self.encoder.encode_system(tool_context.system_context), "options": self.encoder.encode_options(tool_context.model_options), } # provider stream을 순회하면서 raw event를 runtime event로 변환해 내보냅니다. async def stream(self, request): async for raw in self.client.open_stream(request): event = self.translate(raw) if event is not None: yield event # provider별 event 종류를 제품 내부에서 쓰는 표준 RuntimeEvent로 매핑합니다. def translate(self, raw): if raw.kind == "text_piece": return RuntimeEvent("assistant_text_delta", text=raw.value) if raw.kind == "tool_call_done": return RuntimeEvent("tool_request_ready", tool_request=decode_tool_request(raw)) if raw.kind == "usage": normalized = normalize_usage(raw.usage) return RuntimeEvent("usage_updated", usage=normalized) if raw.kind == "done": return RuntimeEvent("assistant_finished", reason=raw.reason)
여기서 provider adapter는 내부 runtime event만 밖으로 내보냅니다. 이 contract가 안정적이면 provider 교체가 쉬워집니다.
7. AI 활용 개발자 관점
AI 도구 사용자는 provider boundary를 직접 보지는 못합니다. 하지만 다음 현상으로 품질을 느낄 수 있습니다.
- 모델이 바뀌어도 UI와 도구 실행 흐름이 안정적인가?
- stream 중간에 비용 정보가 업데이트되는가?
- 도구 schema가 너무 많아 모델이 엉뚱한 도구를 고르지 않는가?
- provider 오류가 발생했을 때 사용자에게 이해 가능한 메시지가 나오는가?
- fallback이 있더라도 transcript와 비용 기록이 일관적인가?
8. Agent 개발자 체크리스트
# 읽는 법: 아래 항목은 동작 흐름을 빠르게 확인하기 위한 요약 예시입니다.Provider API Boundary 체크리스트 [ ] provider SDK 타입이 session shell과 tool runtime으로 새지 않는다.[ ] 내부 message를 provider payload로 바꾸는 단일 조립 지점이 있다.[ ] tool schema selection 정책이 명시되어 있다.[ ] stream event는 내부 RuntimeEvent로 변환된다.[ ] usage/cost는 stream 중간에도 갱신된다.[ ] retry/fallback/non-streaming path가 같은 event contract를 유지한다.[ ] provider별 feature 차이는 adapter 내부에 격리된다.
실패 사례: provider SDK 타입이 제품 전체로 번진 경우
초기에는 provider SDK의 message 타입을 그대로 쓰는 것이 빠릅니다. messages, tools, stream, usage가 이미 있으니 내부 타입을 따로 만들 필요가 없어 보입니다. 하지만 두 번째 provider를 붙이거나, streaming과 non-streaming fallback을 같이 운영하거나, tool schema를 workspace별로 다르게 선택하기 시작하면 SDK 타입은 제품 로직 전체를 묶어버립니다.
예를 들어 UI가 provider의 chunk event 이름을 직접 알고 있으면 provider를 바꾸는 순간 UI도 바뀝니다. ledger가 provider별 usage 필드를 직접 저장하면 비용 리포트가 provider마다 다른 의미를 갖습니다. tool runtime이 provider tool schema를 직접 만들면 같은 도구도 provider별로 다른 validation 규칙을 갖게 됩니다. 결국 adapter가 아니라 제품 전체가 provider integration이 됩니다.
비교표: 얇은 wrapper와 진짜 boundary
| 구분 | 얇은 SDK wrapper | Provider API boundary |
|---|---|---|
| 입력 | 앱 message를 거의 그대로 전달 | 내부 message를 provider payload로 변환 |
| 출력 | provider event를 그대로 노출 | RuntimeEvent로 정규화 |
| tool schema | 호출 지점마다 조립 | adapter 진입 전 선택 후 변환 |
| 비용 | provider usage 필드 직접 사용 | 내부 UsageSnapshot으로 변환 |
| fallback | 별도 코드 경로 | 같은 event contract 유지 |
이 차이는 운영 중에 크게 드러납니다. provider timeout이 발생했을 때 boundary가 있으면 retry 정책, 사용자 메시지, ledger 기록을 같은 방식으로 처리할 수 있습니다. wrapper만 있으면 각 화면과 서비스 함수가 provider 오류를 제각각 해석합니다.
구현 예시: 내부 event contract
type RuntimeEvent = | { type: "assistant_text_delta"; turnId: string; text: string } | { type: "tool_request"; turnId: string; callId: string; name: string; input: unknown } | { type: "usage"; turnId: string; inputTokens: number; outputTokens: number; cachedInputTokens?: number } | { type: "provider_error"; turnId: string; retryable: boolean; message: string };
Provider adapter는 OpenAI, Anthropic, local model 같은 외부 형식을 이 contract로 바꿉니다. 나머지 제품은 provider별 chunk 이름을 모릅니다. 덕분에 UI는 streaming delta를 표시하고, ledger는 usage를 저장하고, tool runtime은 request를 처리하는 데 집중할 수 있습니다. provider 교체는 여전히 어렵지만 blast radius가 adapter 경계 안으로 줄어듭니다.
실패 대응 예시: streaming provider에서 non-streaming fallback으로 전환
Provider boundary의 가치는 장애 때 드러납니다. streaming 호출이 중간에 끊겼을 때 제품 로직이 raw SDK event에 묶여 있으면 fallback 구현이 화면, ledger, 비용 계산에 각각 흩어집니다. 반면 내부 event contract가 있으면 fallback도 같은 RuntimeEvent를 내보내면 됩니다.
| 상황 | adapter가 해야 할 일 | 제품 계층이 기대하는 것 |
|---|---|---|
| stream timeout | retryable error event 생성 | 사용자 안내와 재시도 정책 |
| fallback 성공 | text delta와 finished event 생성 | 기존 UI 흐름 유지 |
| usage 누락 | estimated usage 표시 | 비용 리포트에 추정값 표시 |
| tool schema 차이 | provider별 schema 변환 | tool runtime contract 유지 |
예를 들어 Anthropic stream에서 tool call delta가 여러 조각으로 오고, 다른 provider는 완성된 tool call만 준다고 해봅시다. 제품 전체가 이 차이를 알 필요는 없습니다. adapter가 내부적으로 조각을 모아 tool_request_ready event를 만들면 query loop는 같은 방식으로 tool runtime을 호출합니다. 이 비교가 중요한 이유는 provider 교체가 단순 비용 최적화가 아니라 장애 대응 전략이 되기 때문입니다.
경계 사례는 fallback provider가 같은 기능을 지원하지 않는 경우입니다. 예를 들어 원 provider는 특정 response schema를 지원하지만 fallback provider는 지원하지 않을 수 있습니다. 이때 adapter는 조용히 다른 요청을 보내기보다 capability mismatch를 명시해야 합니다. 그래야 제품은 "fallback 가능", "사용자에게 안내", "작업 중단" 중 하나를 정책으로 고를 수 있습니다.
마무리
Provider API boundary는 보이지 않지만 agent 제품성을 크게 좌우합니다. 이 경계가 얇고 명확해야 provider 변경, fallback, 비용 추적, tool schema 최적화를 안정적으로 할 수 있습니다.
다음 글에서는 tool runtime을 보겠습니다. 모델이 도구를 요청했다고 바로 실행하면 안 됩니다. registry, schema validation, permission, orchestration이 필요합니다.
Key takeaways
- Provider API boundary is not a simple SDK wrapper.
- Translates internal messages, tool schema, system context, and model options into provider payload.
- The streaming response must be converted to an internal runtime event.
- If the provider SDK type leaks throughout the session shell, tool runtime, and ledger, maintenance becomes difficult.
In this article, we will look at the model provider API boundary.
The agent runtime naturally depends on the model API. However, the entire product should not be caught up in the type and event name of the provider SDK. This is because the entire app shakes when you change the provider, add a fallback, or change the streaming processing method.
If you look at Claude Code-type agents from a runtime perspective, the model call boundary is not a simple wrapper. This layer translates between internal messages and provider payloads, selects tool schemas, converts streaming responses into runtime events, and tracks usage and cost.
1. Problems that occur when the Provider SDK type leaks throughout the app
At first, it is convenient to use the provider SDK type as is. However, this becomes a problem as the agent grows.
- The session shell needs to know the provider event name.
- ledger must read the provider usage format directly.
- The tool runtime is bound to the provider tool schema format.
- To add a fallback model, all layers must be modified.
- It is difficult to mock the provider stream in tests.
Therefore, the internal runtime must have its own event vocabulary.
# How to read: The items below are summarized examples to quickly check the action flow.provider stream event→ provider adapter→ runtime event→ shell / ledger / accounting / tool loop
2. API boundary responsibility
| responsibility | explanation |
|---|---|
| message encoding | Convert internal message to provider payload |
| system context assembly | Assemble system prompt, policy hint, workspace context |
| tool schema selection | Select the tool schema to expose in the current turn |
| option mapping | Convert model, token limit, output format, budget hint |
| stream translation | Convert provider event to runtime event |
| usage accounting | Accumulate token/cache/cost information in internal format |
| error handling | retry, fallback, non-streaming path abstraction |
With this layer, even if the model provider changes, other layers of the agent runtime are likely to remain the same.
3. Message encoding and tool schema selection
It is not cost-effective to always send all tool schemas to the model. Conversely, if the required tools are missing, the model will not be able to make appropriate requests.
Therefore, the API boundary must have a policy for selecting the tools needed for the current turn.
| tool type | exposure strategy |
|---|---|
| Basic read-only tools | Can be exposed in most turns |
| write tool | Permission mode and restrictions based on user request |
| Expensive external tools | Only exposed on explicit request or command |
| plugin tool | Exposure after passing availability and policy |
Tool schema selection is connected to the tool runtime, but it is cleaner to do the final assembly into the provider payload at the API boundary.
4. Stream event translation
The stream event structure may be different for each provider. For internal runtime, you only need to know the following common events.
| internal event | meaning |
|---|---|
assistant_started | start assistant response |
assistant_text_delta | partial text |
tool_request_ready | Create an executable tool request |
usage_updated | Usage Update |
assistant_finished | assistant response ends |
provider_error | provider error |
This way, the session shell only needs to know that it is “displaying partial text” and does not need to know what raw events the provider gave.
5. Usage and cost accounting
In streaming API, usage information may not come only at the end. It may be updated as an intermediate event, and detailed items such as cache token or reasoning token may be expressed differently for each provider.
Therefore, it is better to have a structure where usage continues to be absorbed in the middle of the stream rather than being read once from the final response.
caution: In a long-running agent, if cost tracking is calculated only at the end, errors may increase in situations of intermediate cancellation, retry, fallback, and compaction. If budget enforcement is necessary, mid-stream usage updates must be processed.
6. Provider adapter viewed through concept code
The code below is newly written code for explanation purposes.
# How to read: Conceptual code that describes runtime boundaries, not a clone of the actual implementation.class ProviderAdapter: # Initializes runtime dependencies and state storage that the object will reference in later steps. def __init__(self, client, encoder, pricing): self.client = client self.encoder = encoder self.pricing = pricing # Encodes the runtime message and tool context into a request payload that the provider understands. def build_request(self, messages, tool_context): return { "messages": self.encoder.encode_messages(messages), "tools": self.encoder.encode_tools(select_tools(tool_context)), "system": self.encoder.encode_system(tool_context.system_context), "options": self.encoder.encode_options(tool_context.model_options), } # Converts raw events into runtime events and emits them while traversing the provider stream. async def stream(self, request): async for raw in self.client.open_stream(request): event = self.translate(raw) if event is not None: yield event # Map event types for each provider to the standard RuntimeEvent used inside the product. def translate(self, raw): if raw.kind == "text_piece": return RuntimeEvent("assistant_text_delta", text=raw.value) if raw.kind == "tool_call_done": return RuntimeEvent("tool_request_ready", tool_request=decode_tool_request(raw)) if raw.kind == "usage": normalized = normalize_usage(raw.usage) return RuntimeEvent("usage_updated", usage=normalized) if raw.kind == "done": return RuntimeEvent("assistant_finished", reason=raw.reason)
Here, the provider adapter only exports internal runtime events. If this contract is stable, provider replacement becomes easy.
7. AI utilization developer perspective
AI tool users do not see provider boundaries directly. But you can feel the quality by the following phenomenon:
- Are the UI and tool execution flow stable even when the model changes?
- Is cost information updated mid-stream?
- Are there too many tool schemas and the model is picking the wrong tools?
- Provider When an error occurs, is an understandable message displayed to the user?
- Even with fallbacks, are transcripts and cost records consistent?
8. Agent developer checklist
# How to read: The items below are summarized examples to quickly check the action flow.Provider API Boundary Checklist [ ] The provider SDK type does not leak to the session shell and tool runtime.[ ] There is a single assembly point that turns the internal message into a provider payload.[ ] The tool schema selection policy is specified.[ ] Stream event is converted to internal RuntimeEvent.[ ] usage/cost is updated even in the middle of the stream.[ ] Retry/fallback/non-streaming path maintains the same event contract.[ ] Feature differences for each provider are isolated inside the adapter.
Failure case: When the provider SDK type spreads to the entire product
Initially, it is faster to use the message type of the provider SDK as is. Sincemessages,tools,stream, andusagealready exist, there seems to be no need to create a separate internal type. However, if you add a second provider, operate streaming and non-streaming fallback together, or select the tool schema differently for each workspace, the SDK type binds the entire product logic.
For example, if the UI directly knows the provider's chunk event name, the moment you change the provider, the UI will also change. If ledger directly stores the usage field for each provider, cost reports will have different meanings for each provider. If the tool runtime directly creates the provider tool schema, the same tool will have different validation rules for each provider. Ultimately, the entire product, not just the adapter, becomes provider integration.
Comparison table: thin wrapper and real boundary
| division | Thin SDK wrapper | Provider API boundary |
|---|---|---|
| input | Deliver app messages almost as is | Convert internal message to provider payload |
| output of power | Exposing the provider event as is | Normalize to RuntimeEvent |
| tool schema | Assemble at each call point | Convert after selecting before entering adapter |
| expense | Using the provider usage field directly | Convert to internal UsageSnapshot |
| fallback | Separate code path | Maintain the same event contract |
This difference becomes apparent during operation. When a provider timeout occurs, if there is a boundary, retry policies, user messages, and ledger records can be processed in the same way. All you need is a wrapper, and each screen and service function interprets provider errors independently.
Implementation example: internal event contract
type RuntimeEvent = | { type: "assistant_text_delta"; turnId: string; text: string } | { type: "tool_request"; turnId: string; callId: string; name: string; input: unknown } | { type: "usage"; turnId: string; inputTokens: number; outputTokens: number; cachedInputTokens?: number } | { type: "provider_error"; turnId: string; retryable: boolean; message: string };
Provider adapter converts external formats such as OpenAI, Anthropic, and local model into this contract. The remaining products do not know the chunk name for each provider. This allows the UI to display streaming deltas, the ledger to store usage, and the tool runtime to focus on processing requests. Replacing providers is still difficult, but the blast radius is reduced to within the adapter boundaries.
Failure response example: switching from streaming provider to non-streaming fallback
The value of the provider boundary is revealed when there is a failure. If the product logic is tied to a raw SDK event when a streaming call is interrupted, the fallback implementation is scattered to the screen, ledger, and cost calculation, respectively. On the other hand, if there is an internal event contract, you can exportRuntimeEvent, which also has the same fallback.
| situation | What the adapter should do | What the product layer expects |
|---|---|---|
| stream timeout | Create a retryable error event | User guidance and retry policy |
| fallback success | Create text delta and finished event | Maintain existing UI flow |
| missing usage | Show estimated usage | Display estimates in cost reports |
| tool schema differences | Schema conversion by provider | Maintain tool runtime contract |
For example, let's say that a tool call delta from an Anthropic stream comes in several pieces, and another provider only gives completed tool calls. The entire product does not need to know this difference. When the adapter internally puts together the pieces and creates atool_request_readyevent, the query loop calls the tool runtime in the same way. The reason this comparison is important is because provider replacement becomes a failure response strategy rather than a simple cost optimization.
A boundary case is when the fallback provider does not support the same functionality. For example, the original provider may support a specific response schema, but the fallback provider may not. At this time, the adapter should specify a capability mismatch rather than quietly sending another request. Then, the product can choose between “possible fallback”, “inform user”, or “stop operation” as its policy.
finish
Although the Provider API boundary is not visible, it greatly influences agent productivity. This boundary must be thin and clear to enable reliable provider changes, fallbacks, cost tracking, and tool schema optimization.
In the next article, we will look at tool runtime. You should not run a tool immediately after the model requests it. Registry, schema validation, permission, and orchestration are required.

댓글
GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다. 댓글은 GitHub Discussions를 통해 운영됩니다.