Chuyển đến nội dung
Provider tương thích OpenAI và provider registry

Provider tương thích OpenAI và provider registry

Tổng quan

Chứng minh phần abstraction của phase 3 bằng cách cài adapter thứ hai. Provider tương thích OpenAI tự tổng hợp phần framing block mà Anthropic cung cấp sẵn: BlockStart ở delta tool call đầu tiên mang id và name, argument của tool được gom theo Index, và BlockStop cho mọi block còn mở tại finish_reason. Thêm một provider registry với seam CredentialLookup được tiêm vào, việc chọn provider và model theo từng agent, và kiểm tra capability tại thời điểm lưu agent. Một base URL bao phủ được OpenAI, Ollama, OpenRouter, vLLM và Groq.

**Quyết định phạm vi: việc tự mang credential của tenant (BYO) được hoãn sang một việc tiếp theo.** Phase này chỉ resolve credential từ cấu hình server do operator đặt (`HANGO_ANTHROPIC_API_KEY`, `HANGO_OPENAI_API_KEY`, `HANGO_OPENAI_BASE_URL`). Không có bảng `provider_credentials`, không có `internal/auth/crypt.go`, không AES-GCM, không rotate khóa, không có endpoint credential, không có `HANGO_ENCRYPTION_KEY*`. Seam `CredentialLookup` có mặt và được thực thi, nên việc thêm credential theo từng tenant sau này là một bản cài đặt mới của một hàm cộng một bảng — không phải thiết kế lại. Phần việc tiếp theo trong `plan.md` ghi lại các yêu cầu mà công việc đó phải đáp ứng (allowlist chống SSRF cho `base_url` do tenant cung cấp, AES-GCM với AAD gắn tenant và provider, không cache trong bộ nhớ các secret đã resolve). Các phụ thuộc là `[5, 8, 10]`, không phải `[3, 4]`: phase này sửa `internal/run/worker.go` (phase 5), được kiểm chứng bởi bộ test loop có chạy filesystem middleware (phase 8), và cờ `Capabilities.PromptCaching` của nó được `PromptCacheMiddleware` (phase 10) sử dụng. Vì vậy số migration của nó cũng đứng sau phase 10.

Yêu cầu

Chức năng

  • internal/provider/openai cài provider.Provider trên Chat Completions của openai-go/v3 (không dùng Responses API: hango sở hữu state của session).
  • Tổng hợp event: gom Choices[0].Delta.ToolCalls[i] theo Index; phát BlockStart khi một tool call lần đầu có đủ ID+Name, ToolInputDelta cho mỗi mẩu argument, BlockStop cho mọi block còn mở tại finish_reason; phát MessageStop với stop reason đã ánh xạ và usage. Không dùng ChatCompletionAccumulator (tài liệu ghi là không an toàn với parallel tool call).
  • Ánh xạ stop reason: stop→EndTurn, length→MaxTokens, tool_calls→ToolUse, content_filter→Refusal; lỗi độ dài context ánh xạ sang ErrContextOverflow.
  • Thinking block không được hỗ trợ: adapter bỏ chúng khỏi các message gửi đi và báo capability là không có, để một agent bật thinking trên một provider OpenAI thất bại ở bước validation lúc lưu agent chứ không phải giữa chừng một run.
  • Registry: `Get(ctx, providerName)` gọi `CredentialLookup` được tiêm vào; ở phase này bản cài duy nhất đọc cấu hình server do operator đặt. Một provider không biết hoặc một provider chưa cấu hình credential trả về một lỗi có kiểu, được hiển thị thành 422 lúc lưu agent và lúc tạo run, không bao giờ là 500 giữa chừng một run.
  • `HANGO_OPENAI_BASE_URL` do operator đặt được kiểm tra lúc khởi động: scheme phải là `https`, hoặc `http` chỉ cho host loopback. Giá trị sai làm khởi động thất bại cùng với các lỗi config khác. (Base URL do tenant cung cấp, vốn cần một allowlist SSRF đầy đủ, sẽ đến cùng việc BYO-credential được hoãn.)
  • Agent chọn provider và model; validation từ chối một provider không resolve được credential ngay lúc lưu.
  • `Capabilities` chỉ mang `Thinking` và `PromptCaching`. `PromptCaching` được `PromptCacheMiddleware` (phase 10) dùng, nó bỏ qua việc đặt breakpoint khi giá trị này là false. `ParallelTools` bị xóa: tool batch thực thi tuần tự ở v1 (phase 9), nên sẽ chẳng có gì đọc nó.

Phi chức năng

  • Credential chỉ tồn tại bên trong registry lúc dựng client: không bao giờ được log, không bao giờ được endpoint nào trả về, không bao giờ được đặt lên span.
  • Registry là toàn cục theo tiến trình và không giữ state theo tenant: nó giữ các client đã dựng được khóa theo danh tính của credential đã resolve, và CredentialLookup được hỏi ở mỗi lần resolve. Không có cache khóa theo tenant nào để rò sang tenant khác khi credential BYO xuất hiện.
  • Adapter được test với các fixture chunk đã ghi lại, gồm parallel tool call với các mẩu argument xen kẽ nhau.
  • Chính các test loop đang qua với fake Anthropic phải qua với fake OpenAI mà không đổi loop: sự tương đương đó mới là sản phẩm bàn giao thật sự của phase này.

Kiến trúc

// internal/provider/registry.go
type Registry struct {
	creds CredentialLookup // injected; today server config, later a store. Red team: D1
	build map[string]Factory
}

type CredentialLookup func(ctx context.Context, providerName string) (Credential, bool, error)

type Credential struct {
	APIKey       string
	BaseURL      string
	DefaultModel string
}

type Factory func(Credential) (Provider, error)

func (r *Registry) Get(ctx context.Context, name string) (Provider, error) // lookup -> build -> ErrNoProvider

// internal/provider/openai/stream.go — the synthesis core
type blockAccum struct {
	index   int
	id      string
	name    string
	argsBuf strings.Builder
	started bool
}
// on each chunk: for every ToolCalls[i] delta -> ensure accum by Index, emit BlockStart once id+name known,
// emit ToolInputDelta for Arguments fragments; on finish_reason -> emit BlockStop for all open accums,
// then MessageStop with mapped StopReason and Usage.

// internal/provider/capability.go
type Capabilities struct {
	Thinking      bool // consumed by agent save-time validation
	PromptCaching bool // consumed by PromptCacheMiddleware (phase 10)
	// Red team: F15 — ParallelTools deleted: nothing consumes it (batches run sequentially in v1).
}

type CapabilityAware interface{ Capabilities() Capabilities }
Phase này không có migration và không có bảng mới. `provider_credentials` và bộ máy mã hóa của nó được hoãn cùng việc BYO-credential tiếp theo.

Instrumentation

  • Helper dùng chung internal/provider/trace.go nghĩa là adapter OpenAI phát ra đúng hình dạng span chat {model} với gen_ai.provider.name=openai (và gen_ai.system=openai), nên mọi dashboard và view Langfuse hoạt động không đổi cho provider thứ hai. Một test khẳng định các attribute giống nhau giữa các adapter cho một stream kịch bản tương đương.
  • Attribute span riêng của adapter: hango.provider.base_url_host (chỉ host, không bao giờ có credential), số hango.provider.synthesized_blocks, hango.provider.parallel_tool_calls.
  • Span của registry: span INTERNAL provider.resolve với gen_ai.provider.name và hango.provider.credential_source (hiện là server_config; attribute này có sẵn để việc BYO sau này thêm tenant mà không phải đổi dashboard).
  • Metric: các metric GenAI hiện có thêm attribute provider (đã có sẵn), cộng counter hango.provider.resolutions (attribute provider, source, error.type).
  • Trường log: việc resolve credential log provider, nguồn và host của base URL — không bao giờ log key hay một URL đầy đủ kèm query string.

Các file liên quan

Tạo mới

  • internal/provider/openai/provider.go, internal/provider/openai/convert.go, internal/provider/openai/stream.go, internal/provider/openai/stream_test.go, internal/provider/openai/testdata/*.json.
  • internal/provider/registry.go, internal/provider/registry_test.go, internal/provider/capability.go.
  • internal/provider/parity_test.go — chạy cùng một cuộc hội thoại kịch bản qua cả hai adapter, đẩy event vào một provider.Accumulator cho mỗi adapter, và khẳng định các chuỗi event chuẩn hóa và message đã gom giống hệt nhau (trừ tên provider và thinking).
  • docs/learning/12-second-provider.md.
  • Không có `internal/auth/crypt.go`, không có credential store, không có credential handler, không có migration.

Sửa đổi

  • internal/provider/provider.go — ghi tài liệu về hợp đồng tổng hợp cho các adapter không có sẵn framing block native.
  • internal/app/app.go — dựng registry với CredentialLookup dựa trên config.
  • internal/run/worker.go — resolve provider cho từng run từ registry dựa trên trường provider của agent.
  • internal/server/handlers_agents.go — kiểm tra provider và model lúc lưu, gồm cả việc kiểm tra capability thinking.
  • internal/agent/mwcache.go — bỏ qua việc đặt breakpoint khi Capabilities.PromptCaching là false.
  • internal/config/config.go — HANGO_OPENAI_API_KEY, HANGO_OPENAI_BASE_URL (kiểm tra https-hoặc-loopback lúc khởi động).
  • docs/system-architecture.md, docs/learning/README.md, .env.example.

Các bước triển khai

  1. Thêm github.com/openai/openai-go/v3, ghim chính xác một phiên bản (module này ra bản mới gần như hằng ngày).
  2. Viết internal/provider/capability.go (Thinking, PromptCaching) và cài CapabilityAware trên cả hai adapter; nối PromptCaching vào PromptCacheMiddleware.
  3. Viết internal/provider/openai/convert.go: message của hango → ChatCompletionMessageParamUnion (tool result thành các message role tool khóa theo tool_call_id; system block thành một message system; thinking block bị bỏ kèm một log debug), tool definition của hango → ChatCompletionToolParam với JSON Schema.
  4. Viết internal/provider/openai/stream.go cài đặt việc tổng hợp đúng như phác thảo: một map[int]*blockAccum, text delta theo quy ước là index 0, BlockStart được phát một lần cho mỗi tool call ngay khi có đủ id và name, các mẩu argument được phát dưới dạng ToolInputDelta kèm chỉ số block, mọi block còn mở được đóng tại finish_reason, rồi MessageStop. Không gọi ChatCompletionAccumulator.
  5. Ghi fixture cho: chỉ có text, một tool call, hai parallel tool call với các mẩu argument xen kẽ, một tool call mà name đến ở chunk sau id, kết thúc bằng length, và một lỗi độ dài context. Golden test các chuỗi event được tạo ra và message đã gom.
  6. Viết internal/provider/registry.go với CredentialLookup được tiêm vào (giữ provider không phụ thuộc store), map factory, và các lỗi có kiểu ErrNoProvider/ErrProviderMisconfigured.
  7. Nối internal/app để dựng registry với lookup dựa trên config, kiểm tra HANGO_OPENAI_BASE_URL là https-hoặc-loopback lúc khởi động. Worker resolve provider cho từng run; resolve thất bại làm run thất bại với một lỗi rõ ràng trước lần gọi model đầu tiên.
  8. Viết internal/provider/parity_test.go: chạy cùng một cuộc hội thoại kịch bản (text, một tool call, parallel tool call) qua cả hai fake, gom từng cái qua một provider.Accumulator, và khẳng định các chuỗi event chuẩn hóa và message đã gom khớp nhau; sau đó chạy bộ test loop của phase 5 được tham số hóa theo cả hai adapter.
  9. Thêm validation trong agent handler: lưu một agent có provider thiếu credential, hoặc bật thinking trên một provider không có capability, trả về 422 với một thông báo cụ thể.
  10. Thêm instrumentation và test khớp attribute.
  11. Cập nhật docs và viết learning doc.

Dàn ý tài liệu học

docs/learning/12-second-provider.md phải có:

  • Khái niệm: một abstraction chỉ được chứng minh bởi bản cài đặt thứ hai của nó; “tổng hợp framing block” cụ thể nghĩa là gì.
  • Sự bất đối xứng ở mức wire: content_block_start/input_json_delta/content_block_stop của Anthropic so với Delta.ToolCalls[] của OpenAI có Index, một id và name chỉ xuất hiện ở mẩu đầu tiên, và Arguments là các chuỗi JSON một phần. Kèm một bản ghi chunk có chú thích đặt cạnh bản ghi Anthropic của phase 3.
  • Vì sao ChatCompletionAccumulator.JustFinishedToolCall không an toàn với parallel tool call và hango tự gom theo Index.
  • Vì sao dùng Chat Completions mà không dùng Responses API: hango sở hữu state của session, và Chat Completions là thứ các provider tương thích cài đặt.
  • Các lựa chọn thiết kế: registry với credential lookup được tiêm vào, credential do operator đặt ở v1, các interface capability cho thinking và caching, validation lúc lưu thay vì thất bại giữa chừng.
  • Vì sao BYO credential của tenant được hoãn: hợp đồng yêu cầu API key và quota theo từng tenant, không phải provider key theo từng tenant, và mã hóa cộng rotate khóa cộng một `base_url` của tenant an toàn trước SSRF là một bề mặt bảo mật đáng có một phase riêng. Ghi lại seam giữ được gì và việc tiếp theo phải thêm gì.
  • Đánh đổi: seam bây giờ rồi cài đặt sau so với dựng bảng ngay từ đầu; thất bại lúc lưu so với suy giảm lúc chạy.
  • Phase 3 đã làm đúng gì và phải uốn chỗ nào: ghi lại trung thực mọi thay đổi mô hình chuẩn hóa mà phase này buộc phải làm.
  • So sánh với goclaw: AdapterRegistry name→factory, Registry khóa theo "tenantID/name" với fallback về master tenant và một tenantFromCtx được tiêm vào để tránh import cycle, cộng bộ máy failover/cooldown/model-fallback mà hango bỏ qua.
  • So sánh với deepagents: provider abstraction là của LangChain; sự khác biệt OpenAI/Anthropic vô hình với harness — đúng là thuộc tính mà hango phải tự xây bằng tay.
  • Cách debug tầng này: các span provider.resolve cho thấy một run đã dùng nguồn credential nào; hango.provider.resolutions theo source và error.type bắt lỗi cấu hình sai; vì cả hai adapter phát ra các span chat giống hệt nhau, các view Grafana và Langfuse so sánh được các provider cạnh nhau về độ trễ, token và tỉ lệ lỗi.
  • Bài tập: trỏ adapter OpenAI vào một base URL Ollama cục bộ và chạy cùng một agent; cài một CredentialLookup giả trong test và xem registry resolve qua nó.
  • Ngân sách thời gian: 8h cài đặt + 2h learning doc.

Tiêu chí thành công

  • go build ./... && go vet ./... && go test ./... đều qua.
  • Golden test bao phủ text, một tool call, hai parallel tool call với các mẩu xen kẽ, tool name đến muộn, kết thúc length, và lỗi độ dài context.
  • parity_test.go cho thấy cùng một chuỗi event chuẩn hóa từ cả hai adapter cho một cuộc hội thoại kịch bản tương đương, và bộ loop của phase 5 qua với cả hai mà không đổi loop.
  • Test khớp attribute: cả hai adapter phát ra cùng các key attribute của span chat.
  • CredentialLookup là đường duy nhất tới một credential; một test registry với lookup giả chứng minh registry không bao giờ đọc trực tiếp config hay store.
  • Lúc khởi động, một HANGO_OPENAI_BASE_URL không phải https và không phải loopback bị từ chối với một lỗi config nêu tên biến.
  • Lưu một agent với provider không resolve được, hoặc thinking trên một provider không có capability, trả về 422 (test).
  • Với một provider báo PromptCaching: false, không có cache_control nào xuất hiện trên bất kỳ block nào của request gửi đi (test).
  • Trong Grafana, các run trên cả hai provider xuất hiện trong cùng các view span chat và các metric token/độ trễ tách bạch theo gen_ai.provider.name.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngDấu hiệuPhản ứng định sẵn
Mô hình chuẩn hóa của phase 3 không diễn đạt được các stream OpenAITrung bình x CaoViệc tổng hợp cần các loại event mớiIndex cộng BlockStart/BlockStop tường minh đã được thiết kế cho việc này; nếu vẫn cần thay đổi, nó được làm một lần trong internal/provider và learning doc ghi lại chỗ sót một cách trung thực
Argument của parallel tool call xen kẽ bị hỏngCao x CaoTool được gọi với JSON bị cụt hoặc bị trộnGom nghiêm ngặt theo Index, không bao giờ qua ChatCompletionAccumulator; fixture mẩu xen kẽ là cổng chặn
openai-go/v3 thay đổi liên tục làm hỏng buildCao x ThấpLỗi biên dịch sau một lần nâng cấpGhim chính xác phiên bản; adapter là một package và các thay đổi codegen mang tính cơ học
Credential rò vào log, span hoặc bản dump configTrung bình x Nghiêm trọngMột key hiện ra ở bất kỳ đâuSecret chỉ tồn tại bên trong registry lúc dựng client; lúc khởi động chỉ log host của base URL; không endpoint nào phơi credential vì ở phase này không có endpoint như vậy
BYO credential bị vá vào kém về sauTrung bình x CaoMột phase tương lai cố khóa registry theo tenantSeam CredentialLookup cộng các yêu cầu tường minh của việc tiếp theo (allowlist SSRF, AAD, không cache) được viết ra ngay bây giờ, khi lý lẽ còn mới
Agent bật thinking bị chuyển sang OpenAI giữa vòng đờiTrung bình x Trung bìnhRun thất bại sau khi đổi providerValidation capability lúc lưu agent cộng một bước kiểm tra trước run làm run thất bại với một lỗi tương đương 422 trước mọi lời gọi model

Rollback

Không có migration nào cần hoàn tác. Gỡ factory OpenAI khỏi registry đưa hệ thống về hành vi chỉ-Anthropic mà không đụng vào các agent có provider là anthropic; các agent trỏ vào openai thất bại ở validation với một 422 rõ ràng thay vì thất bại giữa chừng một run.