mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 08:15:05 +00:00
feat(providers): add BytePlus ModelArk provider with Seedream/Seedance media gen
Add BytePlus ModelArk as a new OpenAI-compatible provider for Seed 2.0 models (chat, vision). Two provider types: standard API and Coding Plan (separate base URLs, same auth). Integrate Seedream image generation (sync API) and Seedance video generation (async polling) into the builtin media tool chain, following the established DashScope/Gemini patterns. - Add WithAuthPrefix option to OpenAIProvider for future non-standard auth - Add ProviderBytePlus/ProviderBytePlusCoding store constants and config - Register provider from config.json and llm_providers DB table - Add BytePlus to media chain routing, priority lists, and dispatch - Create create_image_byteplus.go (Seedream, sync response) - Create create_video_byteplus.go (Seedance, async poll with 5min timeout) - Add BytePlus to web and desktop UI provider type dropdowns - Update provider docs with BytePlus entries Closes #686
This commit is contained in:
1 parent
7a266aee36
commit
aa5158d4a2
12 files changed
+392
-7
No files matched your search
@@ -146,6 +146,30 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) {
|
||||
slog.Info("registered provider", "name", "novita")
|
||||
}
|
||||
|
||||
// BytePlus ModelArk — OpenAI-compatible (standard Bearer auth).
|
||||
if cfg.Providers.BytePlus.APIKey != "" {
|
||||
base := cfg.Providers.BytePlus.APIBase
|
||||
if base == "" {
|
||||
base = store.BytePlusDefaultAPIBase
|
||||
}
|
||||
prov := providers.NewOpenAIProvider("byteplus", cfg.Providers.BytePlus.APIKey, base, store.BytePlusDefaultModel)
|
||||
prov.WithProviderType(store.ProviderBytePlus)
|
||||
registry.Register(prov)
|
||||
slog.Info("registered provider", "name", "byteplus")
|
||||
}
|
||||
|
||||
// BytePlus ModelArk Coding Plan — separate endpoint for developer tools quota.
|
||||
if cfg.Providers.BytePlusCoding.APIKey != "" {
|
||||
base := cfg.Providers.BytePlusCoding.APIBase
|
||||
if base == "" {
|
||||
base = store.BytePlusCodingDefaultAPIBase
|
||||
}
|
||||
prov := providers.NewOpenAIProvider("byteplus-coding", cfg.Providers.BytePlusCoding.APIKey, base, store.BytePlusDefaultModel)
|
||||
prov.WithProviderType(store.ProviderBytePlusCoding)
|
||||
registry.Register(prov)
|
||||
slog.Info("registered provider", "name", "byteplus-coding")
|
||||
}
|
||||
|
||||
// Claude CLI provider (subscription-based, no API key needed)
|
||||
if cfg.Providers.ClaudeCLI.CLIPath != "" {
|
||||
cliPath := cfg.Providers.ClaudeCLI.CLIPath
|
||||
@@ -358,6 +382,22 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
|
||||
base = store.NovitaDefaultAPIBase
|
||||
}
|
||||
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel))
|
||||
case store.ProviderBytePlus:
|
||||
base := p.APIBase
|
||||
if base == "" {
|
||||
base = store.BytePlusDefaultAPIBase
|
||||
}
|
||||
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel)
|
||||
prov.WithProviderType(p.ProviderType)
|
||||
registry.RegisterForTenant(p.TenantID, prov)
|
||||
case store.ProviderBytePlusCoding:
|
||||
base := p.APIBase
|
||||
if base == "" {
|
||||
base = store.BytePlusCodingDefaultAPIBase
|
||||
}
|
||||
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel)
|
||||
prov.WithProviderType(p.ProviderType)
|
||||
registry.RegisterForTenant(p.TenantID, prov)
|
||||
default:
|
||||
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")
|
||||
prov.WithProviderType(p.ProviderType)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 02 - LLM Providers
|
||||
|
||||
GoClaw abstracts LLM communication behind a single `Provider` interface, allowing the agent loop to work with any backend without knowing the wire format. Six concrete implementations exist: Anthropic (native HTTP+SSE), OpenAI-compatible (covering 10+ API endpoints), Claude CLI (local binary), Codex (OAuth-based), ACP (subagent orchestration), and DashScope (Alibaba Qwen with thinking).
|
||||
GoClaw abstracts LLM communication behind a single `Provider` interface, allowing the agent loop to work with any backend without knowing the wire format. Six concrete implementations exist: Anthropic (native HTTP+SSE), OpenAI-compatible (covering 10+ API endpoints), Claude CLI (local binary), Codex (OAuth-based), ACP (subagent orchestration), and DashScope (Alibaba Qwen with thinking). The OpenAI-compatible provider also supports BytePlus ModelArk (Seed 2.0 models with image/video generation).
|
||||
|
||||
---
|
||||
|
||||
@@ -75,6 +75,8 @@ All HTTP-based providers (Anthropic, OpenAI-compatible, Codex) use 300-second ti
|
||||
| bailian | `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.5-plus` | Alibaba Coding API |
|
||||
| zai | `https://api.z.ai/api/paas/v4` | `glm-5` | |
|
||||
| zai-coding | `https://api.z.ai/api/coding/paas/v4` | `glm-5` | |
|
||||
| byteplus | `https://ark.ap-southeast.bytepluses.com/api/v3` | `seed-2-0-lite-260228` | Seed 2.0 models |
|
||||
| byteplus_coding | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | `seed-2-0-lite-260228` | Seed 2.0 Coding Plan |
|
||||
|
||||
---
|
||||
|
||||
@@ -736,6 +738,8 @@ Reasoning behavior:
|
||||
| `internal/providers/schema_cleaner.go` | CleanSchemaForProvider, CleanToolSchemas, recursive schema field removal |
|
||||
| `internal/providers/registry.go` | Provider registry: registration, lookup, lifecycle management |
|
||||
| `cmd/gateway_providers.go` | Provider registration from config and database during gateway startup |
|
||||
| `internal/tools/create_image_byteplus.go` | BytePlus Seedream async image generation (async polling) |
|
||||
| `internal/tools/create_video_byteplus.go` | BytePlus Seedance async video generation (async polling, 2K resolution) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -213,7 +213,9 @@ type ProvidersConfig struct {
|
||||
OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required)
|
||||
ClaudeCLI ClaudeCLIConfig `json:"claude_cli"`
|
||||
ACP ACPConfig `json:"acp"`
|
||||
Novita ProviderConfig `json:"novita"` // Novita AI (OpenAI-compatible endpoint)
|
||||
Novita ProviderConfig `json:"novita"` // Novita AI (OpenAI-compatible endpoint)
|
||||
BytePlus ProviderConfig `json:"byteplus"` // BytePlus ModelArk (Seed 2.0)
|
||||
BytePlusCoding ProviderConfig `json:"byteplus_coding"` // BytePlus ModelArk Coding Plan
|
||||
}
|
||||
|
||||
// OllamaConfig configures a local (or self-hosted) Ollama instance.
|
||||
@@ -284,6 +286,10 @@ func (p *ProvidersConfig) APIBaseForType(providerType string) string {
|
||||
return p.OllamaCloud.APIBase
|
||||
case "novita":
|
||||
return p.Novita.APIBase
|
||||
case "byteplus":
|
||||
return p.BytePlus.APIBase
|
||||
case "byteplus_coding":
|
||||
return p.BytePlusCoding.APIBase
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -311,7 +317,9 @@ func (c *Config) HasAnyProvider() bool {
|
||||
p.OllamaCloud.APIKey != "" ||
|
||||
p.ClaudeCLI.CLIPath != "" ||
|
||||
p.ACP.Binary != "" ||
|
||||
p.Novita.APIKey != ""
|
||||
p.Novita.APIKey != "" ||
|
||||
p.BytePlus.APIKey != "" ||
|
||||
p.BytePlusCoding.APIKey != ""
|
||||
}
|
||||
|
||||
// QuotaWindow defines request limits per time window. Zero means unlimited.
|
||||
|
||||
@@ -24,6 +24,7 @@ type OpenAIProvider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
chatPath string // defaults to "/chat/completions"
|
||||
authPrefix string // auth header prefix, defaults to "Bearer " if empty
|
||||
defaultModel string
|
||||
providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native")
|
||||
client *http.Client
|
||||
@@ -106,11 +107,19 @@ func (p *OpenAIProvider) WithChatPath(path string) *OpenAIProvider {
|
||||
return p
|
||||
}
|
||||
|
||||
// WithAuthPrefix sets a custom Authorization header prefix for providers with non-standard auth formats.
|
||||
// Default is "Bearer " if not set.
|
||||
func (p *OpenAIProvider) WithAuthPrefix(prefix string) *OpenAIProvider {
|
||||
p.authPrefix = prefix
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string { return p.name }
|
||||
func (p *OpenAIProvider) DefaultModel() string { return p.defaultModel }
|
||||
func (p *OpenAIProvider) SupportsThinking() bool { return true }
|
||||
func (p *OpenAIProvider) APIKey() string { return p.apiKey }
|
||||
func (p *OpenAIProvider) APIBase() string { return p.apiBase }
|
||||
func (p *OpenAIProvider) AuthPrefix() string { return p.authPrefix }
|
||||
func (p *OpenAIProvider) ProviderType() string { return p.providerType }
|
||||
|
||||
// schemaProviderName returns the most specific provider identifier for schema normalization.
|
||||
@@ -561,7 +570,11 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body any) (io.ReadCloser
|
||||
if strings.Contains(strings.ToLower(p.apiBase), "azure.com") {
|
||||
httpReq.Header.Set("api-key", p.apiKey)
|
||||
} else {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
prefix := p.authPrefix
|
||||
if prefix == "" {
|
||||
prefix = "Bearer "
|
||||
}
|
||||
httpReq.Header.Set("Authorization", prefix+p.apiKey)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
|
||||
@@ -31,11 +31,18 @@ const (
|
||||
ProviderOllama = "ollama" // local or self-hosted Ollama (no API key)
|
||||
ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required)
|
||||
ProviderACP = "acp" // ACP (Agent Client Protocol) agent subprocess
|
||||
ProviderNovita = "novita" // Novita AI (OpenAI-compatible endpoint)
|
||||
ProviderNovita = "novita" // Novita AI (OpenAI-compatible endpoint)
|
||||
ProviderBytePlus = "byteplus" // BytePlus ModelArk (Seed 2.0 models)
|
||||
ProviderBytePlusCoding = "byteplus_coding" // BytePlus ModelArk Coding Plan
|
||||
|
||||
// Novita AI defaults.
|
||||
NovitaDefaultAPIBase = "https://api.novita.ai/openai"
|
||||
NovitaDefaultModel = "moonshotai/kimi-k2.5"
|
||||
|
||||
// BytePlus ModelArk defaults.
|
||||
BytePlusDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
BytePlusCodingDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/coding/v3"
|
||||
BytePlusDefaultModel = "seed-2-0-lite-260228"
|
||||
)
|
||||
|
||||
// ValidProviderTypes lists all accepted provider_type values.
|
||||
@@ -63,6 +70,8 @@ var ValidProviderTypes = map[string]bool{
|
||||
ProviderOllamaCloud: true,
|
||||
ProviderACP: true,
|
||||
ProviderNovita: true,
|
||||
ProviderBytePlus: true,
|
||||
ProviderBytePlusCoding: true,
|
||||
}
|
||||
|
||||
// LLMProviderData represents an LLM provider configuration.
|
||||
|
||||
@@ -25,7 +25,7 @@ type credentialProvider interface {
|
||||
}
|
||||
|
||||
// imageGenProviderPriority is the default order for image generation providers.
|
||||
var imageGenProviderPriority = []string{"openrouter", "gemini", "openai", "minimax", "dashscope"}
|
||||
var imageGenProviderPriority = []string{"openrouter", "gemini", "openai", "minimax", "dashscope", "byteplus"}
|
||||
|
||||
// imageGenModelDefaults maps provider names to default image generation models.
|
||||
var imageGenModelDefaults = map[string]string{
|
||||
@@ -34,6 +34,7 @@ var imageGenModelDefaults = map[string]string{
|
||||
"gemini": "gemini-2.5-flash-image",
|
||||
"minimax": "image-01",
|
||||
"dashscope": "wan2.6-image",
|
||||
"byteplus": "seedream-5-0-260128",
|
||||
}
|
||||
|
||||
// CreateImageTool generates images using an image generation API.
|
||||
@@ -153,6 +154,8 @@ func (t *CreateImageTool) callProvider(ctx context.Context, cp credentialProvide
|
||||
return callMinimaxImageGen(ctx, cp.APIKey(), cp.APIBase(), model, prompt, params)
|
||||
case "dashscope":
|
||||
return callDashScopeImageGen(ctx, cp.APIKey(), cp.APIBase(), model, prompt, params)
|
||||
case "byteplus":
|
||||
return callBytePlusImageGen(ctx, cp.APIKey(), cp.APIBase(), model, prompt, params)
|
||||
default:
|
||||
return t.callStandardImageGenAPI(ctx, cp.APIKey(), cp.APIBase(), model, prompt, params)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
)
|
||||
|
||||
// bytePlusImageEndpoint derives the Seedream image generation endpoint from the stored api_base.
|
||||
// Media endpoints always use the standard /api/v3 path (not /api/coding/v3).
|
||||
func bytePlusImageEndpoint(apiBase string) string {
|
||||
return bytePlusMediaBase(apiBase) + "/images/generations"
|
||||
}
|
||||
|
||||
// bytePlusMediaBase extracts the host and returns the standard /api/v3 media base.
|
||||
// Strips /chat/completions, /coding/v3, etc. to always use /api/v3 for media endpoints.
|
||||
func bytePlusMediaBase(apiBase string) string {
|
||||
base := strings.TrimRight(apiBase, "/")
|
||||
base = strings.TrimSuffix(base, "/chat/completions")
|
||||
// Strip any versioned path to rebuild consistently
|
||||
for _, suffix := range []string{"/api/coding/v3", "/api/v3", "/v3"} {
|
||||
if before, ok := strings.CutSuffix(base, suffix); ok {
|
||||
return before + "/api/v3"
|
||||
}
|
||||
}
|
||||
return base + "/api/v3"
|
||||
}
|
||||
|
||||
// callBytePlusImageGen calls the BytePlus Seedream image generation API.
|
||||
// Seedream returns results synchronously (no async polling needed).
|
||||
// Endpoint: POST /api/v3/images/generations
|
||||
func callBytePlusImageGen(ctx context.Context, apiKey, apiBase, model, prompt string, params map[string]any) ([]byte, *providers.Usage, error) {
|
||||
size := aspectRatioToBytePlusSize(params)
|
||||
endpoint := bytePlusImageEndpoint(apiBase)
|
||||
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"response_format": "url",
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("create_image: calling BytePlus Seedream API", "model", model, "size", size)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error %d: %s", resp.StatusCode, truncateBytes(respBody, 500))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Data) == 0 || result.Data[0].URL == "" {
|
||||
return nil, nil, fmt.Errorf("no image URL in BytePlus response: %s", truncateBytes(respBody, 300))
|
||||
}
|
||||
|
||||
return downloadImageURL(ctx, result.Data[0].URL)
|
||||
}
|
||||
|
||||
// aspectRatioToBytePlusSize converts aspect_ratio to BytePlus size format.
|
||||
// Seedream supports "1k", "2K", "4K" or "WIDTHxHEIGHT".
|
||||
func aspectRatioToBytePlusSize(params map[string]any) string {
|
||||
if s := GetParamString(params, "size", ""); s != "" {
|
||||
return s
|
||||
}
|
||||
ar := GetParamString(params, "aspect_ratio", "1:1")
|
||||
switch ar {
|
||||
case "16:9":
|
||||
return "1280x720"
|
||||
case "9:16":
|
||||
return "720x1280"
|
||||
case "4:3":
|
||||
return "1024x768"
|
||||
case "3:4":
|
||||
return "768x1024"
|
||||
default:
|
||||
return "1024x1024"
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,14 @@ import (
|
||||
)
|
||||
|
||||
// videoGenProviderPriority is the default order for video generation providers.
|
||||
var videoGenProviderPriority = []string{"gemini", "minimax", "openrouter"}
|
||||
var videoGenProviderPriority = []string{"gemini", "minimax", "openrouter", "byteplus"}
|
||||
|
||||
// videoGenModelDefaults maps provider names to default video generation models.
|
||||
var videoGenModelDefaults = map[string]string{
|
||||
"gemini": "veo-3.1-lite-generate-preview",
|
||||
"minimax": "MiniMax-Hailuo-2.3",
|
||||
"openrouter": "google/veo-3.1-lite-generate-preview",
|
||||
"byteplus": "seedance-1-5-pro",
|
||||
}
|
||||
|
||||
// maxImageToVideoBytes is the maximum image file size for image-to-video (20 MB).
|
||||
@@ -218,6 +219,8 @@ func (t *CreateVideoTool) callProvider(ctx context.Context, cp credentialProvide
|
||||
return t.callGeminiVideoGen(ctx, cp.APIKey(), cp.APIBase(), model, prompt, duration, aspectRatio, params)
|
||||
case "minimax":
|
||||
return callMinimaxVideoGen(ctx, cp.APIKey(), cp.APIBase(), model, params)
|
||||
case "byteplus":
|
||||
return callBytePlusVideoGen(ctx, cp.APIKey(), cp.APIBase(), model, prompt, duration, aspectRatio, params)
|
||||
default:
|
||||
return t.callChatVideoGen(ctx, cp.APIKey(), cp.APIBase(), model, prompt, duration, aspectRatio, params)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
)
|
||||
|
||||
// bytePlusVideoRunEndpoint derives the Seedance video generation submit endpoint.
|
||||
// Media endpoints always use the standard /api/v3 path (not /api/coding/v3).
|
||||
func bytePlusVideoRunEndpoint(apiBase string) string {
|
||||
return bytePlusMediaBase(apiBase) + "/text-to-video-pro/run"
|
||||
}
|
||||
|
||||
// bytePlusVideoStatusEndpoint derives the Seedance video generation poll endpoint.
|
||||
func bytePlusVideoStatusEndpoint(apiBase, taskID string) string {
|
||||
return bytePlusMediaBase(apiBase) + "/text-to-video-pro/status/" + taskID
|
||||
}
|
||||
|
||||
// callBytePlusVideoGen calls the BytePlus Seedance video generation API.
|
||||
// The API is async: POST /text-to-video-pro/run → task id → poll /status/{id}.
|
||||
func callBytePlusVideoGen(ctx context.Context, apiKey, apiBase, model, prompt string, duration int, aspectRatio string, params map[string]any) ([]byte, *providers.Usage, error) {
|
||||
endpoint := bytePlusVideoRunEndpoint(apiBase)
|
||||
|
||||
input := map[string]any{
|
||||
"prompt": prompt,
|
||||
}
|
||||
if aspectRatio != "" {
|
||||
input["ratio"] = aspectRatio
|
||||
}
|
||||
if duration > 0 {
|
||||
input["duration"] = fmt.Sprintf("%d", duration)
|
||||
}
|
||||
resolution := GetParamString(params, "resolution", "480p")
|
||||
input["resolution"] = resolution
|
||||
|
||||
body := map[string]any{
|
||||
"input": input,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("create_video: calling BytePlus Seedance API",
|
||||
"model", model, "duration", duration, "ratio", aspectRatio, "resolution", resolution)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error %d: %s", resp.StatusCode, truncateBytes(respBody, 500))
|
||||
}
|
||||
|
||||
var initResp struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &initResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if initResp.ID == "" {
|
||||
return nil, nil, fmt.Errorf("no task id in BytePlus video response: %s", truncateBytes(respBody, 300))
|
||||
}
|
||||
|
||||
return bytePlusVideoPollTask(ctx, apiKey, apiBase, initResp.ID, client)
|
||||
}
|
||||
|
||||
// bytePlusVideoPollTask polls GET /text-to-video-pro/status/{id} until done.
|
||||
// Video gen typically takes 45s-3min. Allow up to ~5 minutes (300 polls × 1s).
|
||||
func bytePlusVideoPollTask(ctx context.Context, apiKey, apiBase, taskID string, client *http.Client) ([]byte, *providers.Usage, error) {
|
||||
statusURL := bytePlusVideoStatusEndpoint(apiBase, taskID)
|
||||
slog.Info("create_video: BytePlus Seedance task started, polling", "task_id", taskID)
|
||||
|
||||
const maxPolls = 300
|
||||
const pollInterval = 1 * time.Second
|
||||
|
||||
for i := range maxPolls {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
|
||||
pollReq, err := http.NewRequestWithContext(ctx, "GET", statusURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create poll request: %w", err)
|
||||
}
|
||||
pollReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
pollResp, err := client.Do(pollReq)
|
||||
if err != nil {
|
||||
slog.Warn("create_video: BytePlus poll error, retrying", "error", err, "attempt", i+1)
|
||||
continue
|
||||
}
|
||||
|
||||
respBytes, _ := io.ReadAll(pollResp.Body)
|
||||
pollResp.Body.Close()
|
||||
|
||||
if pollResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("poll API error %d: %s", pollResp.StatusCode, truncateBytes(respBytes, 500))
|
||||
}
|
||||
|
||||
var taskResp struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Output *struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
} `json:"output"`
|
||||
}
|
||||
if err := json.Unmarshal(respBytes, &taskResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse poll response: %w", err)
|
||||
}
|
||||
|
||||
switch taskResp.Status {
|
||||
case "succeeded":
|
||||
if taskResp.Output == nil || taskResp.Output.VideoURL == "" {
|
||||
return nil, nil, fmt.Errorf("task succeeded but no video URL")
|
||||
}
|
||||
return bytePlusDownloadVideo(ctx, taskResp.Output.VideoURL)
|
||||
case "failed":
|
||||
return nil, nil, fmt.Errorf("BytePlus video task %s failed", taskID)
|
||||
default:
|
||||
// Log progress every 10 polls to avoid spam
|
||||
if (i+1)%10 == 0 {
|
||||
slog.Info("create_video: BytePlus task pending", "attempt", i+1, "status", taskResp.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("BytePlus video task %s timed out after %d polls", taskID, maxPolls)
|
||||
}
|
||||
|
||||
// bytePlusDownloadVideo downloads a video from a URL and returns the raw bytes.
|
||||
func bytePlusDownloadVideo(ctx context.Context, videoURL string) ([]byte, *providers.Usage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", videoURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("download video: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, nil, fmt.Errorf("download error %d: %s", resp.StatusCode, truncateBytes(body, 300))
|
||||
}
|
||||
|
||||
videoBytes, err := limitedReadAll(resp.Body, maxMediaDownloadBytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read video data: %w", err)
|
||||
}
|
||||
|
||||
return videoBytes, nil, nil
|
||||
}
|
||||
@@ -305,6 +305,8 @@ var dbTypeToMediaType = map[string]string{
|
||||
"bailian": "dashscope",
|
||||
"anthropic_native": "anthropic",
|
||||
"suno": "suno",
|
||||
"byteplus": "byteplus",
|
||||
"byteplus_coding": "byteplus",
|
||||
}
|
||||
|
||||
// ResolveProviderType returns the media routing type for a provider.
|
||||
@@ -345,6 +347,8 @@ func providerTypeFromName(name string) string {
|
||||
return "anthropic"
|
||||
case name == "suno" || strings.HasPrefix(name, "suno"):
|
||||
return "suno"
|
||||
case name == "byteplus" || strings.HasPrefix(name, "byteplus"):
|
||||
return "byteplus"
|
||||
case name == "yescale":
|
||||
return "openai"
|
||||
default:
|
||||
|
||||
@@ -23,6 +23,8 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [
|
||||
{ value: 'yescale', label: 'YesScale', apiBase: 'https://api.yescale.one/v1', needsKey: true },
|
||||
{ value: 'zai', label: 'Z.ai API', apiBase: 'https://api.z.ai/api/paas/v4', needsKey: true },
|
||||
{ value: 'zai_coding', label: 'Z.ai Coding Plan', apiBase: 'https://api.z.ai/api/coding/paas/v4', needsKey: true },
|
||||
{ value: 'byteplus', label: 'BytePlus ModelArk', apiBase: 'https://ark.ap-southeast.bytepluses.com/api/v3', needsKey: true },
|
||||
{ value: 'byteplus_coding', label: 'BytePlus Coding Plan', apiBase: 'https://ark.ap-southeast.bytepluses.com/api/coding/v3', needsKey: true },
|
||||
{ value: 'ollama', label: 'Ollama (Local)', apiBase: 'http://localhost:11434/v1', needsKey: false },
|
||||
{ value: 'ollama_cloud', label: 'Ollama Cloud', apiBase: 'https://ollama.com/v1', needsKey: true },
|
||||
{ value: 'claude_cli', label: 'Claude CLI (Local)', apiBase: '', needsKey: false },
|
||||
|
||||
@@ -30,6 +30,8 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [
|
||||
{ value: "yescale", label: "YesScale", apiBase: "https://api.yescale.one/v1", placeholder: "" },
|
||||
{ value: "zai", label: "Z.ai API", apiBase: "https://api.z.ai/api/paas/v4", placeholder: "" },
|
||||
{ value: "zai_coding", label: "Z.ai Coding Plan", apiBase: "https://api.z.ai/api/coding/paas/v4", placeholder: "" },
|
||||
{ value: "byteplus", label: "BytePlus ModelArk", apiBase: "https://ark.ap-southeast.bytepluses.com/api/v3", placeholder: "" },
|
||||
{ value: "byteplus_coding", label: "BytePlus Coding Plan", apiBase: "https://ark.ap-southeast.bytepluses.com/api/coding/v3", placeholder: "" },
|
||||
{ value: "ollama", label: "Ollama (Local)", apiBase: "http://localhost:11434/v1", placeholder: "" },
|
||||
{ value: "ollama_cloud", label: "Ollama Cloud", apiBase: "https://ollama.com/v1", placeholder: "" },
|
||||
{ value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" },
|
||||
|
||||
Reference in new issue
Block a user