| 1 | package openai |
| 2 | |
| 3 | import ( |
| 4 | "net/url" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // StepFun's coding-plan surface lives under /step_plan/v1 on both official |
| 9 | // regional hosts. The docs hand Anthropic-SDK users a base without /v1 |
| 10 | // (https://api.stepfun.com/step_plan) because that SDK appends /v1/messages |
| 11 | // itself; pasting the same base into an OpenAI-compatible client yields |
| 12 | // {base}/chat/completions — a 404, while the model probe's {base}/v1/models |
| 13 | // fallback still succeeds, so setup looks healthy and every call fails. |
| 14 | // Canonicalization preserves the user's regional host; only the path moves. |
| 15 | const ( |
| 16 | stepFunPlanChatPath = "/step_plan/v1/chat/completions" |
| 17 | stepFunPlanModelsPath = "/step_plan/v1/models" |
| 18 | ) |
| 19 | |
| 20 | // canonicalStepFunPlanEndpoint rewrites known official StepFun step_plan URLs |
| 21 | // to their complete form. Unknown or unsafe URLs stay untouched. |
| 22 | func canonicalStepFunPlanEndpoint(raw, canonicalPath string) (string, bool) { |
| 23 | raw = strings.TrimSpace(raw) |
| 24 | if raw == "" { |
| 25 | return "", false |
| 26 | } |
| 27 | if strings.ContainsAny(raw, "?#") { |
| 28 | return "", false |
| 29 | } |
| 30 | u, err := url.Parse(raw) |
| 31 | if err != nil || u.Scheme != "https" || u.Host == "" || u.Opaque != "" { |
| 32 | return "", false |
| 33 | } |
| 34 | if !isStepFunPlanHost(u.Hostname()) { |
| 35 | return "", false |
| 36 | } |
| 37 | if port := u.Port(); port != "" && port != "443" { |
| 38 | return "", false |
| 39 | } |
| 40 | if u.User != nil || u.RawPath != "" { |
| 41 | return "", false |
| 42 | } |
| 43 | if !stepFunPlanKnownPath(u.Path) { |
| 44 | return "", false |
| 45 | } |
| 46 | return "https://" + strings.ToLower(u.Hostname()) + canonicalPath, true |
| 47 | } |
| 48 | |
| 49 | func canonicalStepFunPlanChatURL(raw string) (string, bool) { |
| 50 | return canonicalStepFunPlanEndpoint(raw, stepFunPlanChatPath) |
| 51 | } |
| 52 | |
| 53 | // CanonicalStepFunPlanModelsURL rewrites known official StepFun step_plan URLs |
| 54 | // to GET /step_plan/v1/models. |
| 55 | func CanonicalStepFunPlanModelsURL(raw string) (string, bool) { |
| 56 | return canonicalStepFunPlanEndpoint(raw, stepFunPlanModelsPath) |
| 57 | } |
| 58 | |
| 59 | // isStepFunPlanHost matches the two official API hosts exactly. Both stepfun.com |
| 60 | // (China) and stepfun.ai (global) serve step_plan; relays and lookalikes stay |
| 61 | // untouched. |
| 62 | func isStepFunPlanHost(host string) bool { |
| 63 | switch strings.ToLower(strings.TrimSpace(host)) { |
| 64 | case "api.stepfun.com", "api.stepfun.ai": |
| 65 | return true |
| 66 | default: |
| 67 | return false |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func stepFunPlanKnownPath(path string) bool { |
| 72 | if path != "/" { |
| 73 | path = strings.TrimSuffix(path, "/") |
| 74 | } |
| 75 | switch path { |
| 76 | case "/step_plan", "/step_plan/v1", "/step_plan/v1/v1": |
| 77 | return true |
| 78 | } |
| 79 | for _, leaf := range []string{"/chat/completions", "/models"} { |
| 80 | for _, prefix := range []string{"/step_plan", "/step_plan/v1", "/step_plan/v1/v1"} { |
| 81 | if path == prefix+leaf || path == prefix+leaf+leaf { |
| 82 | return true |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | return false |
| 87 | } |
| 88 |